diff --git a/docs/contributing/platforms/index.mdx b/docs/contributing/platforms/index.mdx index 0d661302e59df..0a3c8eb2f1e75 100644 --- a/docs/contributing/platforms/index.mdx +++ b/docs/contributing/platforms/index.mdx @@ -68,6 +68,21 @@ A list of categories for future support. Known values are `browser`, `mobile`, ` The key to use for defaults. Can use full key syntax of `platformName` or `platformName.guideName`. +`fallbackGuide` + +Guide-only. The fully qualified parent guide key, such as `javascript.node`. +The parent guide must belong to the same platform. Cross-platform fallbacks are +rejected during support resolution. +The guide inherits shared-content support rules from this guide. Support is +resolved from the current guide through its fallback chain. An explicit +`supported` or `notSupported` entry for the current guide takes precedence over +inherited and category-based rules; otherwise, category exclusions take +precedence over inherited fallback support. + +Use `fallbackGuide` when the guide implements the parent guide's documented +features and APIs. Use explicit support rules or guide-owned pages for exceptions. +Setting `inheritCommonContent: false` still disables all common-page inheritance. + `sdk` The name of the SDK, if available. Used to embed SDK information on pages. diff --git a/docs/platforms/javascript/common/agent-tracing/flue.mdx b/docs/platforms/javascript/common/agent-tracing/flue.mdx index 7265653b0c474..360b51d692d25 100644 --- a/docs/platforms/javascript/common/agent-tracing/flue.mdx +++ b/docs/platforms/javascript/common/agent-tracing/flue.mdx @@ -7,6 +7,9 @@ supported: - javascript.node - javascript.eve - javascript.cloudflare +notSupported: + - javascript.firebase + - javascript.gcp-functions --- [Flue](https://flueframework.com/) is an open TypeScript framework for building AI agents, made by the Astro team. Flue ships an official Sentry blueprint that installs the Sentry SDK, wires Flue's OpenTelemetry instrumentation into it, and bridges Flue's runtime events to Sentry. You don't need to add Sentry calls to each agent or tool. diff --git a/docs/platforms/javascript/common/configuration/integrations/contextlines.mdx b/docs/platforms/javascript/common/configuration/integrations/contextlines.mdx index 4b9dfd4029415..39021f5d47a26 100644 --- a/docs/platforms/javascript/common/configuration/integrations/contextlines.mdx +++ b/docs/platforms/javascript/common/configuration/integrations/contextlines.mdx @@ -1,6 +1,10 @@ --- title: ContextLines description: "Adds source code from inline JavaScript of the current page's HTML." +supported: + - javascript + - javascript.firebase + - javascript.elysia notSupportedCategories: - server-only notSupported: diff --git a/docs/platforms/javascript/common/install/esm__v8.x.mdx b/docs/platforms/javascript/common/install/esm__v8.x.mdx index 3796d110427da..bbaa6c9abdbf0 100644 --- a/docs/platforms/javascript/common/install/esm__v8.x.mdx +++ b/docs/platforms/javascript/common/install/esm__v8.x.mdx @@ -9,6 +9,9 @@ supported: - javascript.hapi - javascript.hono - javascript.koa +notSupported: + - javascript.firebase + - javascript.gcp-functions noindex: true --- diff --git a/docs/platforms/javascript/common/profiling/index.mdx b/docs/platforms/javascript/common/profiling/index.mdx index 38e10b04e7699..dfed1a004a9bc 100644 --- a/docs/platforms/javascript/common/profiling/index.mdx +++ b/docs/platforms/javascript/common/profiling/index.mdx @@ -4,6 +4,9 @@ sidebar_title: Profiling sidebar_order: 8 sidebar_section: features description: "Collect & view performance insights for JavaScript programs with Sentry's Profiling integrations. Get started with profiling to understand your application's performance." +supported: + - javascript + - javascript.elysia notSupported: - javascript.bun - javascript.cordova diff --git a/platform-includes/sourcemaps/legacy-uploading-methods/javascript.mdx b/platform-includes/sourcemaps/legacy-uploading-methods/javascript.mdx index df7829193bb77..5f885fbcf659e 100644 --- a/platform-includes/sourcemaps/legacy-uploading-methods/javascript.mdx +++ b/platform-includes/sourcemaps/legacy-uploading-methods/javascript.mdx @@ -98,7 +98,11 @@ module.exports = { sourcemaps: { // As you're enabling client source maps, you probably want to delete them after they're uploaded to Sentry. // Set the appropriate glob pattern for your output folder - some glob examples below: - filesToDeleteAfterUpload: ["./**/*.map", ".*/**/public/**/*.map", "./dist/**/client/**/*.map"] + filesToDeleteAfterUpload: [ + "./**/*.map", + ".*/**/public/**/*.map", + "./dist/**/client/**/*.map", + ], }, // Auth tokens can be obtained from @@ -163,7 +167,11 @@ module.exports = { sourcemaps: { // As you're enabling client source maps, you probably want to delete them after they're uploaded to Sentry. // Set the appropriate glob pattern for your output folder - some glob examples below: - filesToDeleteAfterUpload: ["./**/*.map", ".*/**/public/**/*.map", "./dist/**/client/**/*.map"] + filesToDeleteAfterUpload: [ + "./**/*.map", + ".*/**/public/**/*.map", + "./dist/**/client/**/*.map", + ], }, release: { @@ -232,7 +240,11 @@ require("esbuild").build({ sourcemaps: { // As you're enabling client source maps, you probably want to delete them after they're uploaded to Sentry. // Set the appropriate glob pattern for your output folder - some glob examples below: - filesToDeleteAfterUpload: ["./**/*.map", ".*/**/public/**/*.map", "./dist/**/client/**/*.map"] + filesToDeleteAfterUpload: [ + "./**/*.map", + ".*/**/public/**/*.map", + "./dist/**/client/**/*.map", + ], }, release: { @@ -328,7 +340,7 @@ To verify that the distribution has been set correctly in the SDK, open an issue If you've uploaded source maps and they aren't applying to your code in an issue in Sentry, take a look at the JSON of the event and look for the `abs_path` to see exactly where we're attempting to resolve the file - for example, `http://localhost:8000/scripts/script.js` (`abs_path` will appear once for each frame in the stack trace - match this up with the file(s) that are not deminified.). A link to the JSON view can be found at the top of the issue page next to the date the event occurred. The uploaded artifact names must match these values. -If you have **dynamic values in your path** (for example, `https://www.site.com/{some_value}/scripts/script.js`), you may want to use the `rewriteFrames` integration`rewriteFrames` integration to change your `abs_path` values. +If you have **dynamic values in your path** (for example, `https://www.site.com/{some_value}/scripts/script.js`), you may want to use the `rewriteFrames` integration`rewriteFrames` integration to change your `abs_path` values. #### Using sentry-cli diff --git a/src/commonContentInheritance.spec.ts b/src/commonContentInheritance.spec.ts index 8346aa3c3bd3f..a67825eafe87a 100644 --- a/src/commonContentInheritance.spec.ts +++ b/src/commonContentInheritance.spec.ts @@ -31,6 +31,8 @@ beforeAll(async () => { 'override', 'browser-guide', 'server-guide', + 'node', + 'express', ]; await Promise.all([ @@ -55,6 +57,28 @@ beforeAll(async () => { path.join(commonPath, 'only-browser.mdx'), frontmatter('Only browser', 'supportedCategories:\n - browser\n') ), + writeFile( + path.join(commonPath, 'node-only.mdx'), + frontmatter('Node only', 'supported:\n - test.node\n') + ), + writeFile( + path.join(commonPath, 'not-on-node.mdx'), + frontmatter('Not on node', 'notSupported:\n - test.node\n') + ), + writeFile( + path.join(commonPath, 'express-override.mdx'), + frontmatter( + 'Express override', + 'supported:\n - test.express\nnotSupported:\n - test.node\n' + ) + ), + writeFile( + path.join(commonPath, 'node-page-excluded-from-server.mdx'), + frontmatter( + 'Node page excluded from server', + 'supported:\n - test.node\nnotSupportedCategories:\n - server\n' + ) + ), writeFile( path.join(guidesPath, 'index-opt-out', 'index.mdx'), frontmatter('Index opt-out', 'inheritCommonContent: false\n') @@ -84,6 +108,11 @@ beforeAll(async () => { path.join(guidesPath, 'server-guide', 'index.mdx'), frontmatter('Server guide', 'categories:\n - server\n') ), + writeFile(path.join(guidesPath, 'node', 'index.mdx'), frontmatter('Node')), + writeFile( + path.join(guidesPath, 'express', 'index.mdx'), + frontmatter('Express', 'fallbackGuide: test.node\ncategories:\n - server\n') + ), writeFile( path.join(guidesPath, 'override', 'index.mdx'), frontmatter('Override', 'inheritCommonContent: false\n') @@ -139,6 +168,19 @@ describe.each([ expect(slugs.has('platforms/test/guides/browser-guide/only-browser')).toBe(true); expect(slugs.has('platforms/test/guides/server-guide/only-browser')).toBe(false); }); + + test('inherits support rules through fallbackGuide with exact overrides', async () => { + const slugs = new Set( + (await collectFrontmatter(docsPath)).map(({slug}) => slug.replace(/\/index$/, '')) + ); + + expect(slugs.has('platforms/test/guides/express/node-only')).toBe(true); + expect(slugs.has('platforms/test/guides/express/not-on-node')).toBe(false); + expect(slugs.has('platforms/test/guides/express/express-override')).toBe(true); + expect( + slugs.has('platforms/test/guides/express/node-page-excluded-from-server') + ).toBe(false); + }); }); describe('getSourcePathsBySlug', () => { diff --git a/src/components/platformSection/index.spec.ts b/src/components/platformSection/index.spec.ts new file mode 100644 index 0000000000000..f6c6d62bfe9c8 --- /dev/null +++ b/src/components/platformSection/index.spec.ts @@ -0,0 +1,68 @@ +import {describe, expect, it, vi} from 'vitest'; + +import {DocNode, getGuide} from '../../docTree'; +import {isPlatformSupported} from '.'; + +vi.mock('sentry-docs/serverContext', () => ({serverContext: vi.fn()})); + +function node( + slug: string, + children: DocNode[] = [], + frontmatter: Partial = {} +): DocNode { + return { + slug, + path: slug, + children, + missing: false, + frontmatter: {title: slug, slug, ...frontmatter}, + }; +} + +function tree(fallbackGuide: string, nodeFallbackGuide?: string) { + return node('', [ + node('platforms', [ + node('javascript', [ + node('guides', [ + node('express', [], {fallbackGuide}), + node('node', [], {fallbackGuide: nodeFallbackGuide}), + ]), + ]), + node('python', [node('guides', [node('django')])]), + ]), + ]); +} + +describe('PlatformSection support', () => { + it.each(['python.django', 'python.missing'])( + 'rejects cross-platform fallback %s whether the target exists or not', + fallbackGuide => { + const root = tree(fallbackGuide); + const guide = getGuide(root, 'javascript', 'express')!; + + expect(() => isPlatformSupported(root, guide, ['javascript'])).toThrow( + `Invalid fallbackGuide "${fallbackGuide}": expected a guide on platform "javascript".` + ); + } + ); + + it('rejects a cross-platform fallback deeper in the chain', () => { + const root = tree('javascript.node', 'python.django'); + const guide = getGuide(root, 'javascript', 'express')!; + + expect(() => isPlatformSupported(root, guide, ['javascript'])).toThrow( + 'Invalid fallbackGuide "python.django": expected a guide on platform "javascript".' + ); + }); + + it('retains guide overrides and the parent platform for same-platform fallbacks', () => { + const root = tree('javascript.node'); + const guide = getGuide(root, 'javascript', 'express')!; + + expect(isPlatformSupported(root, guide, ['javascript'])).toBe(true); + expect(isPlatformSupported(root, guide, [], ['javascript.node'])).toBe(false); + expect( + isPlatformSupported(root, guide, ['javascript.express'], ['javascript.node']) + ).toBe(true); + }); +}); diff --git a/src/components/platformSection/index.tsx b/src/components/platformSection/index.tsx index b8acbcd78d8ee..25a7f08445427 100644 --- a/src/components/platformSection/index.tsx +++ b/src/components/platformSection/index.tsx @@ -1,19 +1,52 @@ -import {DocNode, getCurrentPlatformOrGuide, getPlatform} from 'sentry-docs/docTree'; +import { + DocNode, + getCurrentPlatformOrGuide, + getGuide, + getPlatform, +} from 'sentry-docs/docTree'; +import { + getFallbackGuideName, + isPlatformSupported as resolvePlatformSupport, +} from 'sentry-docs/platformSupport'; import {serverContext} from 'sentry-docs/serverContext'; import {Platform, PlatformGuide} from 'sentry-docs/types'; import styles from './style.module.css'; -function getPlatformsWithFallback( +function getPlatformSupportKeys( rootNode: DocNode, platformOrGuide: Platform | PlatformGuide -) { - const result = [platformOrGuide.key]; - let curPlatform: Platform | PlatformGuide | undefined = platformOrGuide; - while (curPlatform?.fallbackPlatform) { - result.push(curPlatform.fallbackPlatform); - curPlatform = getPlatform(rootNode, curPlatform.fallbackPlatform); +): string[] { + const result: string[] = []; + const visited = new Set(); + let current: Platform | PlatformGuide | undefined = platformOrGuide; + + while (current && !visited.has(current.key)) { + result.push(current.key); + visited.add(current.key); + + if (current.type === 'guide') { + const parentPlatform = current.platform; + if (current.fallbackGuide && !visited.has(current.fallbackGuide)) { + const fallbackGuide = current.fallbackGuide; + const guideName = getFallbackGuideName(parentPlatform, fallbackGuide); + const fallback = getGuide(rootNode, parentPlatform, guideName); + if (fallback) { + current = fallback; + continue; + } + result.push(fallbackGuide); + visited.add(fallbackGuide); + } + current = getPlatform(rootNode, parentPlatform); + continue; + } + + current = current.fallbackPlatform + ? getPlatform(rootNode, current.fallbackPlatform) + : undefined; } + return result; } @@ -25,20 +58,6 @@ type Props = { supported?: string[]; }; -const isSupported = ( - platformKey: string, - supported: string[], - notSupported: string[] -): boolean | null => { - if (supported.length && supported.find(p => p === platformKey)) { - return true; - } - if (notSupported.length && notSupported.find(p => p === platformKey)) { - return false; - } - return null; -}; - /** * Resolves the same `supported` / `notSupported` rules as `PlatformSection` * for a platform or guide, walking its fallback chain. @@ -49,20 +68,10 @@ export function isPlatformSupported( supported: string[] = [], notSupported: string[] = [] ): boolean { - const platformsToSearch = getPlatformsWithFallback(rootNode, platformOrGuide); - - let result: boolean | null = null; - - for (const platformKey of platformsToSearch) { - result = isSupported(platformKey, supported, notSupported); - if (result !== null) { - break; - } - } - if (result === false) { - return false; - } - return result === true || supported.length === 0; + return resolvePlatformSupport(getPlatformSupportKeys(rootNode, platformOrGuide), { + supported, + notSupported, + }); } /** diff --git a/src/frontmatter.ts b/src/frontmatter.ts index 1c393842cba9c..97bc9025e4b18 100644 --- a/src/frontmatter.ts +++ b/src/frontmatter.ts @@ -8,7 +8,8 @@ import {limitFunction} from 'p-limit'; import {apiCategories} from './build/resolveOpenAPI'; import getAllFilesRecursively from './files'; import {readGuideConfig, shouldInheritCommonContent} from './guideConfig'; -import {FrontMatter, PlatformCategory, PlatformConfig} from './types'; +import {getGuideSupportKeys, isPlatformSupported} from './platformSupport'; +import {FrontMatter, PlatformConfig} from './types'; import {isNotNil} from './utils'; import {VERSION_INDICATOR} from './versioning'; @@ -22,43 +23,6 @@ const FILE_CONCURRENCY_LIMIT = 200; const formatSlug = (slug: string): string => slug.replace(/\.(mdx|md)$/, ''); -const isSupported = ( - frontmatter: FrontMatter, - platformName: string, - guideName?: string, - categories: PlatformCategory[] = [] -): boolean => { - const canonical = guideName ? `${platformName}.${guideName}` : platformName; - - const matchesCategory = (list?: PlatformCategory[]) => - !!list?.some(category => categories.includes(category)); - - const hasAllowlist = - !!frontmatter.supported?.length || !!frontmatter.supportedCategories?.length; - if (hasAllowlist) { - // An exact guide match always wins. - if (frontmatter.supported?.includes(canonical)) { - return true; - } - // Otherwise a platform-level or category allowlist match keeps the page, but - // still lets the notSupported lists below filter it out. - if ( - !frontmatter.supported?.includes(platformName) && - !matchesCategory(frontmatter.supportedCategories) - ) { - return false; - } - } - if ( - frontmatter.notSupported?.includes(canonical) || - frontmatter.notSupported?.includes(platformName) || - matchesCategory(frontmatter.notSupportedCategories) - ) { - return false; - } - return true; -}; - let getDocsFrontMatterCache: Promise | undefined; export function getDocsFrontMatter(): Promise { @@ -205,10 +169,9 @@ export async function getDocsFrontMatterFromDirectory( limitFunction( commonFile => { if ( - !isSupported( + !isPlatformSupported( + [platformName], commonFile.frontmatter, - platformName, - undefined, platformFrontmatter.categories ) ) { @@ -291,10 +254,9 @@ export async function getDocsFrontMatterFromDirectory( limitFunction( commonFile => { if ( - !isSupported( + !isPlatformSupported( + getGuideSupportKeys(platformName, guideName, guideConfigs), commonFile.frontmatter, - platformName, - guideName, guideFrontmatter.categories ) ) { diff --git a/src/guideConfig.ts b/src/guideConfig.ts index c3271204d5cf6..0c54e49af753e 100644 --- a/src/guideConfig.ts +++ b/src/guideConfig.ts @@ -4,9 +4,11 @@ import path from 'node:path'; import matter from 'gray-matter'; import yaml from 'js-yaml'; -import type {FrontMatter, PlatformConfig} from './types'; +import type {FrontMatter, PlatformConfig, PlatformGuide} from './types'; -type GuideConfig = Partial & PlatformConfig; +type GuideConfig = Partial & + PlatformConfig & + Pick; async function readIndexFrontmatter(guidePath: string): Promise { try { diff --git a/src/mdx.ts b/src/mdx.ts index 9f61d9c722181..bf72e0f6365dc 100644 --- a/src/mdx.ts +++ b/src/mdx.ts @@ -31,6 +31,7 @@ import getAllFilesRecursively from './files'; import {readGuideConfig, shouldInheritCommonContent} from './guideConfig'; import remarkDefList from './mdx-deflist'; import {DocMetrics} from './metrics'; +import {getGuideSupportKeys, isPlatformSupported} from './platformSupport'; import rehypeOnboardingLines from './rehype-onboarding-lines'; import rehypeSlug from './rehype-slug.js'; import remarkCodeTabs from './remark-code-tabs'; @@ -42,7 +43,7 @@ import remarkImageProcessing from './remark-image-processing'; import remarkImageResize from './remark-image-resize'; import remarkTocHeadings, {TocNode} from './remark-toc-headings'; import remarkVariables from './remark-variables'; -import {FrontMatter, Platform, PlatformCategory, PlatformConfig} from './types'; +import {FrontMatter, Platform, PlatformConfig} from './types'; import {isNotNil} from './utils'; import {isVersioned, stripVersion, VERSION_INDICATOR} from './versioning'; @@ -148,43 +149,6 @@ async function writeCacheFile(file: string, data: string) { function formatSlug(slug: string) { return slug.replace(/\.(mdx|md)/, ''); } -const isSupported = ( - frontmatter: FrontMatter, - platformName: string, - guideName?: string, - categories: PlatformCategory[] = [] -): boolean => { - const canonical = guideName ? `${platformName}.${guideName}` : platformName; - - const matchesCategory = (list?: PlatformCategory[]) => - !!list?.some(category => categories.includes(category)); - - const hasAllowlist = - !!frontmatter.supported?.length || !!frontmatter.supportedCategories?.length; - if (hasAllowlist) { - // An exact guide match always wins. - if (frontmatter.supported?.includes(canonical)) { - return true; - } - // Otherwise a platform-level or category allowlist match keeps the page, but - // still lets the notSupported lists below filter it out. - if ( - !frontmatter.supported?.includes(platformName) && - !matchesCategory(frontmatter.supportedCategories) - ) { - return false; - } - } - if ( - frontmatter.notSupported?.includes(canonical) || - frontmatter.notSupported?.includes(platformName) || - matchesCategory(frontmatter.notSupportedCategories) - ) { - return false; - } - return true; -}; - let getDocsFrontMatterCache: Promise | undefined; export function getDocsFrontMatter(): Promise { @@ -396,10 +360,9 @@ export async function getAllFilesFrontMatter( commonFiles.map(f => limit(async () => { if ( - !isSupported( + !isPlatformSupported( + [platformName], f.frontmatter, - platformName, - undefined, platformFrontmatter.categories ) ) { @@ -438,13 +401,23 @@ export async function getAllFilesFrontMatter( continue; } + const guideNames: string[] = []; for await (const guide of await opendir(guidesPath)) { - if (guide.isFile()) { - continue; + if (!guide.isFile()) { + guideNames.push(guide.name); } - const guideName = guide.name; + } + const guideConfigs = new Map( + await Promise.all( + guideNames.map( + async guideName => + [guideName, await readGuideConfig(path.join(guidesPath, guideName))] as const + ) + ) + ); - const guideFrontmatter = await readGuideConfig(path.join(guidesPath, guideName)); + for (const guideName of guideNames) { + const guideFrontmatter = guideConfigs.get(guideName) || {}; // Standalone framework guides opt out of platform common/ inheritance. if (!shouldInheritCommonContent(guideFrontmatter)) { @@ -455,10 +428,9 @@ export async function getAllFilesFrontMatter( commonFiles.map(f => limit(async () => { if ( - !isSupported( + !isPlatformSupported( + getGuideSupportKeys(platformName, guideName, guideConfigs), f.frontmatter, - platformName, - guideName, guideFrontmatter.categories ) ) { diff --git a/src/platformSupport.test.ts b/src/platformSupport.test.ts new file mode 100644 index 0000000000000..ede119bfd4caa --- /dev/null +++ b/src/platformSupport.test.ts @@ -0,0 +1,103 @@ +import {describe, expect, it} from 'vitest'; + +import {getGuideSupportKeys, isPlatformSupported} from './platformSupport'; + +const guideConfigs = new Map([ + ['node', {}], + ['express', {fallbackGuide: 'javascript.node'}], +]); + +describe('platform support', () => { + it('inherits support rules through fallback guides', () => { + const keys = getGuideSupportKeys('javascript', 'express', guideConfigs); + + expect(keys).toEqual(['javascript.express', 'javascript.node', 'javascript']); + expect(isPlatformSupported(keys, {notSupported: ['javascript.node']})).toBe(false); + }); + + it('lets the current guide override a fallback rule', () => { + const keys = getGuideSupportKeys('javascript', 'express', guideConfigs); + + expect( + isPlatformSupported(keys, { + supported: ['javascript.express'], + notSupported: ['javascript.node'], + }) + ).toBe(true); + }); + + it('treats null support lists as unspecified', () => { + expect(isPlatformSupported(['javascript'], {supported: null})).toBe(true); + }); + + it('preserves category support while inheriting fallback guide rules', () => { + const keys = getGuideSupportKeys('javascript', 'express', guideConfigs); + + expect(isPlatformSupported(keys, {supportedCategories: ['server']}, ['server'])).toBe( + true + ); + expect( + isPlatformSupported(keys, {notSupportedCategories: ['server']}, ['server']) + ).toBe(false); + }); + + it('lets exact guide rules override category and fallback rules', () => { + const keys = getGuideSupportKeys('javascript', 'express', guideConfigs); + + expect( + isPlatformSupported( + keys, + { + supported: ['javascript.express'], + notSupportedCategories: ['server'], + }, + ['server'] + ) + ).toBe(true); + expect( + isPlatformSupported( + keys, + { + supported: ['javascript.node'], + notSupported: ['javascript.express'], + }, + ['server'] + ) + ).toBe(false); + }); + + it.each(['python.django', 'python.missing'])( + 'rejects cross-platform fallback %s during page expansion', + fallbackGuide => { + const configs = new Map([['express', {fallbackGuide}]]); + + expect(() => getGuideSupportKeys('javascript', 'express', configs)).toThrow( + `Invalid fallbackGuide "${fallbackGuide}": expected a guide on platform "javascript".` + ); + } + ); + + it('rejects a cross-platform fallback deeper in the chain', () => { + const configs = new Map([ + ['express', {fallbackGuide: 'javascript.node'}], + ['node', {fallbackGuide: 'python.django'}], + ]); + + expect(() => getGuideSupportKeys('javascript', 'express', configs)).toThrow( + 'Invalid fallbackGuide "python.django": expected a guide on platform "javascript".' + ); + }); + + it('stops when fallback guides form a cycle', () => { + const cyclicConfigs = new Map([ + ['express', {fallbackGuide: 'javascript.node'}], + ['node', {fallbackGuide: 'javascript.express'}], + ]); + + expect(getGuideSupportKeys('javascript', 'express', cyclicConfigs)).toEqual([ + 'javascript.express', + 'javascript.node', + 'javascript', + ]); + }); +}); diff --git a/src/platformSupport.ts b/src/platformSupport.ts new file mode 100644 index 0000000000000..bdf613e2d0d3e --- /dev/null +++ b/src/platformSupport.ts @@ -0,0 +1,88 @@ +import type {PlatformCategory} from './types'; + +export type SupportConfig = { + notSupported?: string[] | null; + notSupportedCategories?: PlatformCategory[] | null; + supported?: string[] | null; + supportedCategories?: PlatformCategory[] | null; +}; + +/** Resolve support rules from the most specific guide to its fallbacks. */ +export function isPlatformSupported( + platformKeys: string[], + supportConfig: SupportConfig, + categories: PlatformCategory[] = [] +): boolean { + const supported = supportConfig.supported ?? []; + const notSupported = supportConfig.notSupported ?? []; + const supportedCategories = supportConfig.supportedCategories ?? []; + const notSupportedCategories = supportConfig.notSupportedCategories ?? []; + + const [currentPlatformKey, ...fallbackKeys] = platformKeys; + if (currentPlatformKey && supported.includes(currentPlatformKey)) { + return true; + } + if (currentPlatformKey && notSupported.includes(currentPlatformKey)) { + return false; + } + + if (notSupportedCategories.some(category => categories.includes(category))) { + return false; + } + + for (const platformKey of fallbackKeys) { + if (supported.includes(platformKey)) { + return true; + } + if (notSupported.includes(platformKey)) { + return false; + } + } + + if (supportedCategories.some(category => categories.includes(category))) { + return true; + } + + return supported.length === 0 && supportedCategories.length === 0; +} + +/** Page expansion only has guide configs for the current platform. */ +export function getFallbackGuideName(platform: string, fallbackGuide: string): string { + const [fallbackPlatform, ...guideParts] = fallbackGuide.split('.'); + if (fallbackPlatform !== platform) { + throw new Error( + `Invalid fallbackGuide "${fallbackGuide}": expected a guide on platform "${platform}".` + ); + } + return guideParts.join('.'); +} + +/** Return support keys for a guide while common pages are being expanded. */ +export function getGuideSupportKeys( + platform: string, + guide: string, + guideConfigs: Map +): string[] { + const result: string[] = []; + const visited = new Set(); + let currentGuide: string | undefined = guide; + + while (currentGuide) { + const key = `${platform}.${currentGuide}`; + if (visited.has(key)) { + break; + } + + result.push(key); + visited.add(key); + + const fallbackGuide = guideConfigs.get(currentGuide)?.fallbackGuide; + if (!fallbackGuide || visited.has(fallbackGuide)) { + break; + } + + currentGuide = getFallbackGuideName(platform, fallbackGuide); + } + + return [...result, platform]; +}