diff --git a/.github/workflows/package-lock-lint.yml b/.github/workflows/package-lock-lint.yml index b912a11cbb2e..1b9581f94536 100644 --- a/.github/workflows/package-lock-lint.yml +++ b/.github/workflows/package-lock-lint.yml @@ -37,6 +37,9 @@ jobs: run: | npm --version + # Save the current top-level dependencies from package-lock.json + node -e "console.log(JSON.stringify(require('./package-lock.json').packages['']))" > /tmp/before.json + # From https://docs.npmjs.com/cli/v7/commands/npm-install # # The --package-lock-only argument will only update the @@ -45,9 +48,16 @@ jobs: # npm install --package-lock-only --ignore-scripts --include=optional - # If the package.json (dependencies and devDependencies) is - # in correct sync with package-lock.json running the above command - # should *not* make an edit to the package-lock.json. I.e. - # running `git status` should - # say "nothing to commit, working tree clean". - git diff --exit-code + # Extract the top-level dependencies after regeneration + node -e "console.log(JSON.stringify(require('./package-lock.json').packages['']))" > /tmp/after.json + + # Compare only the top-level package dependencies + # This ignores platform-specific differences in nested dependency resolution + # (like "peer" flags) that don't affect actual installed versions + if ! diff /tmp/before.json /tmp/after.json; then + echo "ERROR: Top-level dependencies in package-lock.json are out of sync with package.json" + echo "Please run 'npm install' locally and commit the updated package-lock.json" + exit 1 + fi + + echo "✓ Top-level dependencies are in sync" diff --git a/data/reusables/copilot/ccr-model-settings.md b/data/reusables/copilot/ccr-model-settings.md index 2840cf0449be..50ad303d5cc1 100644 --- a/data/reusables/copilot/ccr-model-settings.md +++ b/data/reusables/copilot/ccr-model-settings.md @@ -1,2 +1,4 @@ > [!NOTE] -> {% data variables.copilot.copilot_code-review_short %} may use models that are not enabled on your organization's "Models" settings page. See [AUTOTITLE](/copilot/how-tos/administer-copilot/manage-for-organization/manage-policies). +> {% data variables.copilot.copilot_code-review_short %} may use models that are not enabled on your organization's "Models" settings page. The "Models" settings page only controls {% data variables.copilot.copilot_chat_short %}. +> +> Since {% data variables.copilot.copilot_code-review_short %} is generally available, all model usage will be subject to the generally available terms. See [AUTOTITLE](/copilot/how-tos/administer-copilot/manage-for-organization/manage-policies). diff --git a/eslint-plugins.d.ts b/eslint-plugins.d.ts new file mode 100644 index 000000000000..acbb6a189037 --- /dev/null +++ b/eslint-plugins.d.ts @@ -0,0 +1,57 @@ +// Type declarations for ESLint plugins without official TypeScript definitions + +declare module 'eslint-plugin-github' { + import type { ESLint, Linter } from 'eslint' + + const plugin: ESLint.Plugin & { + configs: { + recommended: Linter.FlatConfig + } + } + + export default plugin +} + +declare module 'eslint-plugin-primer-react' { + import type { ESLint, Linter } from 'eslint' + + const plugin: ESLint.Plugin & { + configs: { + recommended: Linter.FlatConfig + } + } + + export default plugin +} + +declare module 'eslint-plugin-eslint-comments' { + import type { ESLint } from 'eslint' + + const plugin: ESLint.Plugin + + export default plugin +} + +declare module 'eslint-plugin-i18n-text' { + import type { ESLint } from 'eslint' + + const plugin: ESLint.Plugin + + export default plugin +} + +declare module 'eslint-plugin-filenames' { + import type { ESLint } from 'eslint' + + const plugin: ESLint.Plugin + + export default plugin +} + +declare module 'eslint-plugin-no-only-tests' { + import type { ESLint } from 'eslint' + + const plugin: ESLint.Plugin + + export default plugin +} diff --git a/eslint.config.js b/eslint.config.ts similarity index 65% rename from eslint.config.js rename to eslint.config.ts index 8fa152a4b420..aeb30e33a9ad 100644 --- a/eslint.config.js +++ b/eslint.config.ts @@ -14,89 +14,6 @@ import prettier from 'eslint-config-prettier' import globals from 'globals' export default [ - // JavaScript and MJS files configuration - { - files: ['**/*.{js,mjs}'], - languageOptions: { - ecmaVersion: 2022, - sourceType: 'module', - globals: { - ...globals.browser, - ...globals.node, - ...globals.commonjs, - ...globals.es2020, - }, - parserOptions: { - requireConfigFile: false, - }, - }, - settings: { - 'import/resolver': { - typescript: true, - node: true, - }, - }, - plugins: { - github, - import: importPlugin, - 'eslint-comments': eslintComments, - 'i18n-text': i18nText, - filenames, - 'no-only-tests': noOnlyTests, - prettier: prettierPlugin, - }, - rules: { - // ESLint recommended rules - ...js.configs.recommended.rules, - - // GitHub plugin recommended rules - ...github.configs.recommended.rules, - - // Import plugin error rules - ...importPlugin.configs.errors.rules, - - // JavaScript-specific overrides - 'import/no-extraneous-dependencies': [ - 'error', - { - packageDir: '.', - }, - ], - 'import/extensions': 'off', - 'no-console': 'off', - camelcase: 'off', - 'no-shadow': 'off', - 'prefer-template': 'off', - 'no-constant-condition': 'off', - 'no-unused-vars': 'off', - 'import/no-named-as-default-member': 'off', - 'one-var': 'off', - 'import/no-namespace': 'off', - 'import/no-anonymous-default-export': 'off', - 'object-shorthand': 'off', - 'no-empty': 'off', - 'prefer-const': 'off', - 'import/no-named-as-default': 'off', - 'no-useless-concat': 'off', - 'func-style': 'off', - - // Disable GitHub plugin rules that were disabled in original config - 'github/array-foreach': 'off', - 'github/no-then': 'off', - - // Disable rules that might not exist or cause issues initially - 'i18n-text/no-en': 'off', - 'filenames/match-regex': 'off', - 'eslint-comments/no-use': 'off', - 'eslint-comments/no-unused-disable': 'off', - 'eslint-comments/no-unlimited-disable': 'off', - - // Disable new ESLint 9 rules that are causing issues - 'no-constant-binary-expression': 'off', - }, - }, - - // TypeScript and TSX files configuration { files: ['**/*.{ts,tsx}'], languageOptions: { diff --git a/package-lock.json b/package-lock.json index d3f93f38b24f..2a7d689f08f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -116,6 +116,7 @@ "@types/connect-timeout": "1.9.0", "@types/cookie": "0.6.0", "@types/cookie-parser": "1.4.8", + "@types/eslint-plugin-jsx-a11y": "^6.10.1", "@types/event-to-promise": "^0.7.5", "@types/express": "^5.0.3", "@types/imurmurhash": "^0.1.4", @@ -156,6 +157,7 @@ "graphql": "^16.9.0", "http-status-code": "^2.1.0", "husky": "^9.1.7", + "jiti": "^2.6.1", "json-schema-merge-allof": "^0.8.1", "lint-staged": "^16.0.0", "markdownlint": "^0.34.0", @@ -4063,6 +4065,16 @@ "@types/ms": "*" } }, + "node_modules/@types/eslint-plugin-jsx-a11y": { + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/@types/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.1.tgz", + "integrity": "sha512-5RtuPVe0xz8BAhrkn2oww6Uw885atf962Q4fqZo48QdO3EQA7oCEDSXa6optgJ1ZMds3HD9ITK5bfm4AWuoXFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint": "^9" + } + }, "node_modules/@types/estree": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", @@ -10183,6 +10195,15 @@ "version": "2.1.0", "license": "MIT" }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, "node_modules/joi": { "version": "17.13.3", "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.3.tgz", diff --git a/package.json b/package.json index a2e3b945928b..eae47fc34dcc 100644 --- a/package.json +++ b/package.json @@ -258,6 +258,7 @@ "@types/connect-timeout": "1.9.0", "@types/cookie": "0.6.0", "@types/cookie-parser": "1.4.8", + "@types/eslint-plugin-jsx-a11y": "^6.10.1", "@types/event-to-promise": "^0.7.5", "@types/express": "^5.0.3", "@types/imurmurhash": "^0.1.4", @@ -298,6 +299,7 @@ "graphql": "^16.9.0", "http-status-code": "^2.1.0", "husky": "^9.1.7", + "jiti": "^2.6.1", "json-schema-merge-allof": "^0.8.1", "lint-staged": "^16.0.0", "markdownlint": "^0.34.0", diff --git a/src/ai-tools/scripts/ai-tools.ts b/src/ai-tools/scripts/ai-tools.ts index 0d2cc4b25f37..0237c017b2c7 100644 --- a/src/ai-tools/scripts/ai-tools.ts +++ b/src/ai-tools/scripts/ai-tools.ts @@ -1,5 +1,3 @@ -#!/usr/bin/env node - import { fileURLToPath } from 'url' import { Command } from 'commander' import fs from 'fs' diff --git a/src/content-linter/lib/linting-rules/frontmatter-hero-image.ts b/src/content-linter/lib/linting-rules/frontmatter-hero-image.ts new file mode 100644 index 000000000000..e7cd45bd8b17 --- /dev/null +++ b/src/content-linter/lib/linting-rules/frontmatter-hero-image.ts @@ -0,0 +1,89 @@ +import fs from 'fs' +import path from 'path' +// @ts-ignore - markdownlint-rule-helpers doesn't have TypeScript declarations +import { addError } from 'markdownlint-rule-helpers' + +import { getFrontmatter } from '../helpers/utils' +import type { RuleParams, RuleErrorCallback, Rule } from '@/content-linter/types' + +interface Frontmatter { + heroImage?: string + [key: string]: any +} + +// Get the list of valid hero images +function getValidHeroImages(): string[] { + const ROOT = process.env.ROOT || '.' + const heroImageDir = path.join(ROOT, 'assets/images/banner-images') + + try { + if (!fs.existsSync(heroImageDir)) { + return [] + } + + const files = fs.readdirSync(heroImageDir) + // Return absolute paths as they would appear in frontmatter + return files.map((file) => `/assets/images/banner-images/${file}`) + } catch { + return [] + } +} + +export const frontmatterHeroImage: Rule = { + names: ['GHD061', 'frontmatter-hero-image'], + description: + 'Hero image paths must be absolute and point to valid images in /assets/images/banner-images/', + tags: ['frontmatter', 'images'], + function: (params: RuleParams, onError: RuleErrorCallback) => { + // Only check index.md files + if (!params.name.endsWith('index.md')) return + + const fm = getFrontmatter(params.lines) as Frontmatter | null + if (!fm || !fm.heroImage) return + + const heroImage = fm.heroImage + + // Check if heroImage is an absolute path + if (!heroImage.startsWith('/')) { + const line = params.lines.find((line: string) => line.trim().startsWith('heroImage:')) + const lineNumber = line ? params.lines.indexOf(line) + 1 : 1 + addError( + onError, + lineNumber, + `Hero image path must be absolute (start with /). Found: ${heroImage}`, + line || '', + null, // No fix possible + ) + return + } + + // Check if heroImage points to banner-images directory + if (!heroImage.startsWith('/assets/images/banner-images/')) { + const line = params.lines.find((line: string) => line.trim().startsWith('heroImage:')) + const lineNumber = line ? params.lines.indexOf(line) + 1 : 1 + addError( + onError, + lineNumber, + `Hero image must point to /assets/images/banner-images/. Found: ${heroImage}`, + line || '', + null, // No fix possible + ) + return + } + + // Check if the file actually exists + const validHeroImages = getValidHeroImages() + if (validHeroImages.length > 0 && !validHeroImages.includes(heroImage)) { + const line = params.lines.find((line: string) => line.trim().startsWith('heroImage:')) + const lineNumber = line ? params.lines.indexOf(line) + 1 : 1 + const availableImages = validHeroImages.join(', ') + addError( + onError, + lineNumber, + `Hero image file does not exist: ${heroImage}. Available images: ${availableImages}`, + line || '', + null, // No fix possible + ) + } + }, +} diff --git a/src/content-linter/lib/linting-rules/index.ts b/src/content-linter/lib/linting-rules/index.ts index eaa9706da08f..d3cabfa4489d 100644 --- a/src/content-linter/lib/linting-rules/index.ts +++ b/src/content-linter/lib/linting-rules/index.ts @@ -61,6 +61,7 @@ import { ctasSchema } from '@/content-linter/lib/linting-rules/ctas-schema' import { journeyTracksLiquid } from './journey-tracks-liquid' import { journeyTracksGuidePathExists } from './journey-tracks-guide-path-exists' import { journeyTracksUniqueIds } from './journey-tracks-unique-ids' +import { frontmatterHeroImage } from './frontmatter-hero-image' // 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 @@ -130,6 +131,7 @@ export const gitHubDocsMarkdownlint = { journeyTracksLiquid, // GHD058 journeyTracksGuidePathExists, // GHD059 journeyTracksUniqueIds, // GHD060 + frontmatterHeroImage, // GHD061 // 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 8d53d4631fcb..eddf742b46e6 100644 --- a/src/content-linter/style/github-docs.ts +++ b/src/content-linter/style/github-docs.ts @@ -348,6 +348,12 @@ export const githubDocsFrontmatterConfig = { 'partial-markdown-files': false, 'yml-files': false, }, + 'frontmatter-hero-image': { + // GHD061 + severity: 'error', + 'partial-markdown-files': false, + 'yml-files': false, + }, } // Configures rules from the `github/markdownlint-github` repo diff --git a/src/content-linter/tests/unit/frontmatter-hero-image.ts b/src/content-linter/tests/unit/frontmatter-hero-image.ts new file mode 100644 index 000000000000..ac3096c70385 --- /dev/null +++ b/src/content-linter/tests/unit/frontmatter-hero-image.ts @@ -0,0 +1,128 @@ +import { describe, expect, test } from 'vitest' + +import { runRule } from '../../lib/init-test' +import { frontmatterHeroImage } from '../../lib/linting-rules/frontmatter-hero-image' + +const fmOptions = { markdownlintOptions: { frontMatter: null } } + +describe(frontmatterHeroImage.names.join(' - '), () => { + test('valid absolute heroImage path passes', async () => { + const markdown = [ + '---', + 'title: Test', + "heroImage: '/assets/images/banner-images/hero-1.png'", + '---', + '', + '# Test', + ].join('\n') + const result = await runRule(frontmatterHeroImage, { + strings: { 'content/test/index.md': markdown }, + ...fmOptions, + }) + const errors = result['content/test/index.md'] + expect(errors.length).toBe(0) + }) + + test('non-index.md file is ignored', async () => { + const markdown = [ + '---', + 'title: Test', + "heroImage: 'invalid-path.png'", + '---', + '', + '# Test', + ].join('\n') + const result = await runRule(frontmatterHeroImage, { + strings: { 'content/test/article.md': markdown }, + ...fmOptions, + }) + const errors = result['content/test/article.md'] + expect(errors.length).toBe(0) + }) + + test('missing heroImage is ignored', async () => { + const markdown = ['---', 'title: Test', '---', '', '# Test'].join('\n') + const result = await runRule(frontmatterHeroImage, { + strings: { 'content/test/index.md': markdown }, + ...fmOptions, + }) + const errors = result['content/test/index.md'] + expect(errors.length).toBe(0) + }) + + test('relative heroImage path fails', async () => { + const markdown = [ + '---', + 'title: Test', + "heroImage: 'images/hero-1.png'", + '---', + '', + '# Test', + ].join('\n') + const result = await runRule(frontmatterHeroImage, { + strings: { 'content/test/index.md': markdown }, + ...fmOptions, + }) + const errors = result['content/test/index.md'] + expect(errors.length).toBe(1) + expect(errors[0].errorDetail).toContain('must be absolute') + }) + + test('non-banner-images path fails', async () => { + const markdown = [ + '---', + 'title: Test', + "heroImage: '/assets/images/other/hero-1.png'", + '---', + '', + '# Test', + ].join('\n') + const result = await runRule(frontmatterHeroImage, { + strings: { 'content/test/index.md': markdown }, + ...fmOptions, + }) + const errors = result['content/test/index.md'] + expect(errors.length).toBe(1) + expect(errors[0].errorDetail).toContain('/assets/images/banner-images/') + }) + + test('non-existent heroImage file fails', async () => { + const markdown = [ + '---', + 'title: Test', + "heroImage: '/assets/images/banner-images/non-existent.png'", + '---', + '', + '# Test', + ].join('\n') + const result = await runRule(frontmatterHeroImage, { + strings: { 'content/test/index.md': markdown }, + ...fmOptions, + }) + const errors = result['content/test/index.md'] + expect(errors.length).toBe(1) + expect(errors[0].errorDetail).toContain('does not exist') + }) + + test('all valid hero images pass', async () => { + // Test each valid hero image + const validImages = [ + "heroImage: '/assets/images/banner-images/hero-1.png'", + "heroImage: '/assets/images/banner-images/hero-2.png'", + "heroImage: '/assets/images/banner-images/hero-3.png'", + "heroImage: '/assets/images/banner-images/hero-4.png'", + "heroImage: '/assets/images/banner-images/hero-5.png'", + "heroImage: '/assets/images/banner-images/hero-6.png'", + ] + + for (const heroImageLine of validImages) { + const markdown = ['---', 'title: Test', heroImageLine, '---', '', '# Test'].join('\n') + const result = await runRule(frontmatterHeroImage, { + strings: { 'content/test/index.md': markdown }, + ...fmOptions, + }) + const errors = result['content/test/index.md'] + expect(errors.length).toBe(0) + } + }) +}) diff --git a/src/fixtures/tests/playwright-rendering.spec.ts b/src/fixtures/tests/playwright-rendering.spec.ts index 17776def96ac..99bd97eb2684 100644 --- a/src/fixtures/tests/playwright-rendering.spec.ts +++ b/src/fixtures/tests/playwright-rendering.spec.ts @@ -1138,22 +1138,6 @@ test.describe('Journey Tracks', () => { expect(trackContent).not.toContain('{%') expect(trackContent).not.toContain('%}') }) - - test('journey navigation components show on article pages', async ({ page }) => { - // go to an article that's part of a journey track - await page.goto('/get-started/start-your-journey/hello-world?feature=journey-navigation') - - // journey next/prev nav components should rende - const journeyCard = page.locator('[data-testid="journey-track-card"]') - if (await journeyCard.isVisible()) { - await expect(journeyCard).toBeVisible() - } - - const journeyNav = page.locator('[data-testid="journey-track-nav"]') - if (await journeyNav.isVisible()) { - await expect(journeyNav).toBeVisible() - } - }) }) test.describe('LandingArticleGridWithFilter component', () => { diff --git a/src/frame/components/article/ArticlePage.tsx b/src/frame/components/article/ArticlePage.tsx index b6d42702003a..89ef49ed8842 100644 --- a/src/frame/components/article/ArticlePage.tsx +++ b/src/frame/components/article/ArticlePage.tsx @@ -7,8 +7,6 @@ import { DefaultLayout } from '@/frame/components/DefaultLayout' import { ArticleTitle } from '@/frame/components/article/ArticleTitle' import { useArticleContext } from '@/frame/components/context/ArticleContext' import { LearningTrackNav } from '@/learning-track/components/article/LearningTrackNav' -import { JourneyTrackNav } from '@/journeys/components/JourneyTrackNav' -import { JourneyTrackCard } from '@/journeys/components/JourneyTrackCard' import { MarkdownContent } from '@/frame/components/ui/MarkdownContent' import { Lead } from '@/frame/components/ui/Lead' import { PermissionsStatement } from '@/frame/components/ui/PermissionsStatement' @@ -44,14 +42,10 @@ export const ArticlePage = () => { productVideoUrl, miniTocItems, currentLearningTrack, - currentJourneyTrack, supportPortalVaIframeProps, currentLayout, } = useArticleContext() const isLearningPath = !!currentLearningTrack?.trackName - const isJourneyPath = !!currentJourneyTrack?.trackId - // Only show journey track components when feature flag is enabled - const showJourneyTracks = isJourneyPath && router.query?.feature === 'journey-navigation' const { t } = useTranslation(['pages']) const introProp = ( @@ -78,7 +72,6 @@ export const ArticlePage = () => { const toc = ( <> {isLearningPath && } - {showJourneyTracks && } {miniTocItems.length > 1 && } ) @@ -129,11 +122,6 @@ export const ArticlePage = () => { ) : null} - {showJourneyTracks ? ( -
- -
- ) : null} ) : (
@@ -160,12 +148,6 @@ export const ArticlePage = () => {
) : null} - - {showJourneyTracks ? ( -
- -
- ) : null} )} diff --git a/src/frame/middleware/resolve-recommended.ts b/src/frame/middleware/resolve-recommended.ts index 842883bf6751..774585f7a510 100644 --- a/src/frame/middleware/resolve-recommended.ts +++ b/src/frame/middleware/resolve-recommended.ts @@ -2,6 +2,7 @@ import type { ExtendedRequest, ResolvedArticle } from '@/types' import type { Response, NextFunction } from 'express' import findPage from '@/frame/lib/find-page' import { renderContent } from '@/content-render/index' +import Permalink from '@/frame/lib/permalink' import { createLogger } from '@/observability/logger/index' @@ -83,12 +84,11 @@ function tryResolveArticlePath( } /** - * Get the href for a page from its permalinks + * Get the path for a page (without language/version) */ -function getPageHref(page: any, currentLanguage: string = 'en'): string { - if (page.permalinks?.length > 0) { - const permalink = page.permalinks.find((p: any) => p.languageCode === currentLanguage) - return permalink ? permalink.href : page.permalinks[0].href +function getPageHref(page: any): string { + if (page.relativePath) { + return Permalink.relativePathToSuffix(page.relativePath) } return '' // fallback } @@ -126,7 +126,6 @@ async function resolveRecommended( return next() } - const currentLanguage = req.context?.currentLanguage || 'en' const resolved: ResolvedArticle[] = [] for (const rawPath of articlePaths) { @@ -134,7 +133,7 @@ async function resolveRecommended( const foundPage = tryResolveArticlePath(rawPath, page?.relativePath, req) if (foundPage) { - const href = getPageHref(foundPage, currentLanguage) + const href = getPageHref(foundPage) const category = foundPage.relativePath ? foundPage.relativePath.split('/').slice(0, -1).filter(Boolean) : [] diff --git a/src/frame/tests/resolve-recommended.test.ts b/src/frame/tests/resolve-recommended.test.ts index 2ed9f26ab12f..d3324608e282 100644 --- a/src/frame/tests/resolve-recommended.test.ts +++ b/src/frame/tests/resolve-recommended.test.ts @@ -120,7 +120,7 @@ describe('resolveRecommended middleware', () => { { title: 'Test Article', intro: '

Test intro

', - href: '/en/copilot/tutorials/article', + href: '/copilot/tutorials/article', category: ['copilot', 'tutorials'], }, ]) @@ -195,7 +195,7 @@ describe('resolveRecommended middleware', () => { { title: 'Valid Article', intro: '

Valid intro

', - href: '/en/test/valid', + href: '/test/valid', category: ['test'], }, ]) @@ -256,10 +256,64 @@ describe('resolveRecommended middleware', () => { { title: 'Relative Article', intro: '

Relative intro

', - href: '/en/copilot/relative-article', + href: '/copilot/relative-article', // Updated to clean path category: ['copilot'], }, ]) expect(mockNext).toHaveBeenCalled() }) + + test('returns paths without language or version prefixes', async () => { + const testPage: Partial = { + mtime: Date.now(), + title: 'Tutorial Page', + rawTitle: 'Tutorial Page', + intro: 'Tutorial intro', + rawIntro: 'Tutorial intro', + relativePath: 'copilot/tutorials/tutorial-page/index.md', + fullPath: '/full/path/copilot/tutorials/tutorial-page/index.md', + languageCode: 'en', + documentType: 'article', + markdown: 'Tutorial content', + versions: {}, + applicableVersions: ['free-pro-team@latest'], + permalinks: [ + { + languageCode: 'en', + pageVersion: 'free-pro-team@latest', + title: 'Tutorial Page', + href: '/en/copilot/tutorials/tutorial-page', + hrefWithoutLanguage: '/copilot/tutorials/tutorial-page', + }, + ], + renderProp: vi.fn().mockResolvedValue('rendered'), + renderTitle: vi.fn().mockResolvedValue('Tutorial Page'), + render: vi.fn().mockResolvedValue('rendered content'), + buildRedirects: vi.fn().mockReturnValue({}), + } + + mockFindPage.mockReturnValue(testPage as any) + + const req = createMockRequest({ rawRecommended: ['/copilot/tutorials/tutorial-page'] }) + + await resolveRecommended(req, mockRes, mockNext) + + expect(mockFindPage).toHaveBeenCalledWith( + '/en/copilot/tutorials/tutorial-page', + req.context!.pages, + req.context!.redirects, + ) + + // Verify that the href is a clean path without language/version, that gets + // added on the React side. + expect((req.context!.page as any).recommended).toEqual([ + { + title: 'Tutorial Page', + intro: '

Tutorial intro

', + href: '/copilot/tutorials/tutorial-page', + category: ['copilot', 'tutorials', 'tutorial-page'], + }, + ]) + expect(mockNext).toHaveBeenCalled() + }) }) diff --git a/src/journeys/lib/journey-path-resolver.ts b/src/journeys/lib/journey-path-resolver.ts index f2a69d4265cc..1bc9b22088bc 100644 --- a/src/journeys/lib/journey-path-resolver.ts +++ b/src/journeys/lib/journey-path-resolver.ts @@ -83,24 +83,6 @@ function normalizeGuidePath(path: string): string { : `/${withoutLanguage || path}` } -/** - * Helper function to append the journey-navigation feature flag to URLs - */ -function appendJourneyFeatureFlag(href: string): string { - if (!href) return href - - try { - // we have to pass some URL here, we just throw it away though - const url = new URL(href, 'https://docs.github.com') - url.searchParams.set('feature', 'journey-navigation') - return url.pathname + url.search - } catch { - // fallback if URL parsing fails - const separator = href.includes('?') ? '&' : '?' - return `${href}${separator}feature=journey-navigation` - } -} - /** * Resolves the journey context for a given article path. * @@ -189,7 +171,7 @@ export async function resolveJourneyContext( if (resultData && resultData.length > 0) { const linkResult = resultData[0] result.prevGuide = { - href: appendJourneyFeatureFlag(linkResult.href), + href: linkResult.href, title: linkResult.title || '', } } @@ -210,7 +192,7 @@ export async function resolveJourneyContext( if (resultData && resultData.length > 0) { const linkResult = resultData[0] result.nextGuide = { - href: appendJourneyFeatureFlag(linkResult.href), + href: linkResult.href, title: linkResult.title || '', } } @@ -251,7 +233,7 @@ export async function resolveJourneyTracks( const linkData = await getLinkData(guidePath, context, { title: true }) const baseHref = linkData?.[0]?.href || guidePath return { - href: appendJourneyFeatureFlag(baseHref), + href: baseHref, title: linkData?.[0]?.title || 'Untitled Guide', } }), diff --git a/src/journeys/tests/journey-path-resolver.ts b/src/journeys/tests/journey-path-resolver.ts index 624d92f06087..510b14a99a8b 100644 --- a/src/journeys/tests/journey-path-resolver.ts +++ b/src/journeys/tests/journey-path-resolver.ts @@ -82,7 +82,7 @@ describe('journey-path-resolver', () => { ) expect(result?.prevGuide).toEqual({ - href: '/en/enterprise-cloud@latest/enterprise-onboarding/setup?feature=journey-navigation', + href: '/en/enterprise-cloud@latest/enterprise-onboarding/setup', title: 'Mock Title for /enterprise-onboarding/setup', }) }) @@ -95,7 +95,7 @@ describe('journey-path-resolver', () => { ) expect(result?.nextGuide).toEqual({ - href: '/en/enterprise-cloud@latest/enterprise-onboarding/deploy?feature=journey-navigation', + href: '/en/enterprise-cloud@latest/enterprise-onboarding/deploy', title: 'Mock Title for /enterprise-onboarding/deploy', }) }) @@ -184,7 +184,7 @@ describe('journey-path-resolver', () => { expect(result[0].guides).toHaveLength(2) expect(result[0].guides[0]).toEqual({ - href: '/en/enterprise-cloud@latest/enterprise-onboarding/setup?feature=journey-navigation', + href: '/en/enterprise-cloud@latest/enterprise-onboarding/setup', title: 'Mock Title for /enterprise-onboarding/setup', }) }) diff --git a/src/landings/components/shared/LandingArticleGridWithFilter.tsx b/src/landings/components/shared/LandingArticleGridWithFilter.tsx index 1009ce4bdcbd..abc3d20205ff 100644 --- a/src/landings/components/shared/LandingArticleGridWithFilter.tsx +++ b/src/landings/components/shared/LandingArticleGridWithFilter.tsx @@ -61,7 +61,9 @@ export const ArticleGrid = ({ flatArticles }: ArticleGridProps) => { // Extract unique categories from the articles const categories: string[] = [ ALL_CATEGORIES, - ...new Set(flatArticles.flatMap((item) => item.category || [])), + ...Array.from(new Set(flatArticles.flatMap((item) => item.category || []))).sort((a, b) => + a.localeCompare(b), + ), ] const applyFilters = () => { diff --git a/src/landings/components/shared/LandingCarousel.tsx b/src/landings/components/shared/LandingCarousel.tsx index f779d1488aba..0ec75e24cf69 100644 --- a/src/landings/components/shared/LandingCarousel.tsx +++ b/src/landings/components/shared/LandingCarousel.tsx @@ -1,8 +1,10 @@ import { useState, useEffect, useRef } from 'react' +import { useRouter } from 'next/router' import { ChevronLeftIcon, ChevronRightIcon } from '@primer/octicons-react' import cx from 'classnames' import type { ResolvedArticle } from '@/types' import { useTranslation } from '@/languages/components/useTranslation' +import { useVersion } from '@/versions/components/useVersion' import styles from './LandingCarousel.module.scss' type LandingCarouselProps = { @@ -42,6 +44,8 @@ export const LandingCarousel = ({ heading = '', recommended }: LandingCarouselPr const [isAnimating, setIsAnimating] = useState(false) const itemsPerView = useResponsiveItemsPerView() const { t } = useTranslation('product_landing') + const router = useRouter() + const { currentVersion } = useVersion() const headingText = heading || t('carousel.recommended') // Ref to store timeout IDs for cleanup const animationTimeoutRef = useRef(null) @@ -145,7 +149,7 @@ export const LandingCarousel = ({ heading = '', recommended }: LandingCarouselPr {visibleItems.map((article: ResolvedArticle, index) => (

diff --git a/src/metrics/scripts/docsaudit.ts b/src/metrics/scripts/docsaudit.ts index f37159959643..a49d6ccce9a4 100644 --- a/src/metrics/scripts/docsaudit.ts +++ b/src/metrics/scripts/docsaudit.ts @@ -1,5 +1,3 @@ -#!/usr/bin/env node - import fs from 'fs' import path from 'path' import { fileURLToPath } from 'url' diff --git a/src/metrics/scripts/docstat.ts b/src/metrics/scripts/docstat.ts index bc1850e09e1c..2e1f6bad7afc 100644 --- a/src/metrics/scripts/docstat.ts +++ b/src/metrics/scripts/docstat.ts @@ -1,5 +1,3 @@ -#!/usr/bin/env node - import fs from 'fs' import path from 'path' import { Command } from 'commander' diff --git a/src/secret-scanning/data/public-docs.yml b/src/secret-scanning/data/public-docs.yml index cf23a1010e22..c72d61a2d935 100644 --- a/src/secret-scanning/data/public-docs.yml +++ b/src/secret-scanning/data/public-docs.yml @@ -2830,7 +2830,7 @@ isPublic: true isPrivateWithGhas: true hasPushProtection: true - hasValidityCheck: false + hasValidityCheck: '{% ifversion ghes %}false{% else %}true{% endif %}' base64Supported: false isduplicate: false - provider: Grafana diff --git a/src/secret-scanning/lib/config.json b/src/secret-scanning/lib/config.json index 715a40a023f4..be868558133c 100644 --- a/src/secret-scanning/lib/config.json +++ b/src/secret-scanning/lib/config.json @@ -1,5 +1,5 @@ { - "sha": "967fe0aa24cea46ddd1b85d5daebfe2f29dec698", - "blob-sha": "4c6a80effd77ff6f5d20f2fc58e0ec359ec58eed", + "sha": "8e351827006cf7e13d1cbbd96853679857f3261d", + "blob-sha": "5dd8a45310fe73ad5a1bb752be594c1adbd3cf38", "targetFilename": "code-security/secret-scanning/introduction/supported-secret-scanning-patterns" } \ No newline at end of file