diff --git a/.remarkrc.mjs b/.remarkrc.mjs index 1eb8f48da0..efba9453fe 100644 --- a/.remarkrc.mjs +++ b/.remarkrc.mjs @@ -1,14 +1,17 @@ import { resolve } from "path"; +import { defaultScopes } from "./.build/server/docs-helpers.mjs"; import remarkVariables from "./.build/server/remark-variables.mjs"; import remarkIncludes from "./.build/server/remark-includes.mjs"; import remarkCodeSnippet from "./.build/server/remark-code-snippet.mjs"; import remarkLintDetails from "./.build/server/remark-lint-details.mjs"; import remarkLintFrontmatter from "./.build/server/remark-lint-frontmatter.mjs"; -import { remarkLintTeleportDocsLinks} from "./.build/server/lint-teleport-docs-links.mjs" +import { remarkLintTeleportDocsLinks } from "./.build/server/lint-teleport-docs-links.mjs"; import { getVersion, getVersionRootPath, + getPageMeta, } from "./.build/server/docs-helpers.mjs"; +import { remarkLintScopes } from "./.build/server/remark-lint-scopes.mjs"; import { loadConfig, loadMessagingConfig, @@ -80,6 +83,18 @@ const configLint = { remarkLintMessaging, loadMessagingConfig(resolve("messaging-config.json")), ], + [ + remarkLintScopes, + (vfile) => { + const scopes = getPageMeta(vfile.path).scopes; + // getPageMeta assigns the "scopes" field to [""] if + // the page's forScopes config includes no scopes. + if (scopes.indexOf("") !== -1 && scopes.length === 1) { + return defaultScopes; + } + return scopes; + }, + ], ], }; diff --git a/server/docs-helpers.ts b/server/docs-helpers.ts index d16567b6b4..504a61c892 100644 --- a/server/docs-helpers.ts +++ b/server/docs-helpers.ts @@ -79,6 +79,13 @@ const findNavItem = ( return undefined; }; +export const defaultScopes: ScopesInMeta = [ + "oss", + "enterprise", + "cloud", + "team", +]; + type AnyNavItem = RawNavigationItem | NavigationItem; type AnyNav = NavigationCategory | AnyNavItem; type CookedNav = NavigationCategory | NavigationItem; @@ -97,7 +104,7 @@ function addScopesToNavigation(nav: AnyNav[]) { const transformedNav: CookedNav[] = []; for (let i = 0; i < nav.length; i++) { - let scopes: ScopesInMeta = ["oss", "team", "cloud", "enterprise"]; + let scopes: ScopesInMeta = defaultScopes; const item = Object.assign({}, nav[i]); if ("forScopes" in item) { diff --git a/server/remark-lint-scopes.ts b/server/remark-lint-scopes.ts new file mode 100644 index 0000000000..66973d49a8 --- /dev/null +++ b/server/remark-lint-scopes.ts @@ -0,0 +1,128 @@ +import { lintRule } from "unified-lint-rule"; +import { visit } from "unist-util-visit"; +import type { VFile } from "vfile"; +import type { Position, Node, Parent } from "unist"; +import { + MdxJsxFlowElement, + ProgramEsmNode, + MdxJsxAttributeValue, +} from "./types-unist"; + +// Matches, for example, "team" and "oss" in: +// '{["team", "oss"]}' +// Used to find scope values in un-executed TS strings used as the values of +// properties in MDAST nodes. +const wordPattern = "(^|\\W)(\\w+)($|\\W)"; + +const parseScopeValue = (val: MdxJsxAttributeValue): string[] => { + let scopeExpr: string; + + if (typeof val === "string") { + scopeExpr = val; + } else { + scopeExpr = (val as ProgramEsmNode).value; + } + const matches = scopeExpr.matchAll(new RegExp(wordPattern, "gm")); + if (matches === null) { + return []; + } + return Array.from(matches).map((match) => { + // The second capture group + return match[2]; + }); +}; + +export const remarkLintScopes = lintRule( + "remark-lint:scopes", + ( + root: Parent, + file: VFile, + getscopes: string[] | ((vfile: VFile) => string[]) + ) => { + let scopes: string[]; + + if (typeof getscopes === "function") { + scopes = getscopes(file); + } else { + scopes = getscopes; + } + + visit(root, (node: MdxJsxFlowElement) => { + if ( + // JSX components. See: + // https://github.com/syntax-tree/mdast-util-mdx-jsx#mdxjsxflowelement-1 + node.type === "mdxJsxFlowElement" && + node.hasOwnProperty("attributes") + ) { + (node as MdxJsxFlowElement).attributes.forEach(({ name, value }) => { + if (name !== "scope" || !value) { + return; + } + const componentScopes = parseScopeValue(value); + const configuredScopeSet = new Set(scopes); + const componentScopeSet = new Set(componentScopes); + const excessScopes = componentScopes.filter((el) => { + return !configuredScopeSet.has(el); + }); + + excessScopes.forEach((el) => { + file.message( + `The page is configured for scopes "${scopes.join( + "," + )}", but the ${ + node.name + } component supports the "${el}" scope. Either fix the ${ + node.name + } component or adjust the forScopes setting for the page in docs/config.json.`, + node.position + ); + }); + }); + + if (node.name === "Tabs") { + let tabScopes = new Set([]); + + // Collect the scopes within all TabItems of a Tabs component and + // ensure that they include all scopes configured for a page. + node.children.forEach((child) => { + if ((child as MdxJsxFlowElement).name !== "TabItem") { + return; + } + (child as MdxJsxFlowElement).attributes.forEach( + ({ name, value }) => { + if (name !== "scope" || !value) { + return; + } + parseScopeValue(value).forEach((scope) => { + tabScopes.add(scope); + }); + } + ); + }); + + // This is not a scoped Tabs component, so don't check the scopes. + if (Array.from(tabScopes).length === 0) { + return; + } + + const missingScopes = scopes.filter((el) => { + return !tabScopes.has(el); + }); + + missingScopes.forEach((el) => { + file.message( + `The page is configured for scopes "${scopes.join( + "," + )}", but the "${el}" scope is missing from a ${ + node.name + } component. Either fix the ${ + node.name + } component or adjust the forScopes setting for the page in docs/config.json.`, + node.position + ); + }); + } + } + }); + } +); diff --git a/uvu-tests/remark-lint-scopes.test.ts b/uvu-tests/remark-lint-scopes.test.ts new file mode 100644 index 0000000000..d164b46d58 --- /dev/null +++ b/uvu-tests/remark-lint-scopes.test.ts @@ -0,0 +1,188 @@ +import { suite } from "uvu"; +import { VFile, VFileOptions } from "vfile"; +import remarkMdx from "remark-mdx"; +import { remark } from "remark"; +import { remarkLintScopes } from "../server/remark-lint-scopes"; +import * as assert from "uvu/assert"; + +const Suite = suite("server/remark-lint-scopes"); + +const transformer = (vfileOptions: VFileOptions, scopes: string[]): VFile => { + const file = new VFile(vfileOptions); + + return remark() + .use(remarkMdx) + .use(remarkLintScopes, scopes) + .processSync(file); +}; + +const getErrors = (result: VFile) => { + if (result.messages === undefined || result.messages.length == 0) { + return []; + } + return result.messages.map(({ message }) => message); +}; + +Suite("returns no errors on a Tabs component with all scopes", () => { + const value = ` +This is a paragraph. + + + +These instructions apply to Enterprise Cloud and Team. + + +These instructions apply to Enterprise. + + +These instructions apply to OSS. + + +`; + const result = transformer( + { + value, + path: "/content/4.0/docs/pages/filename.mdx", + }, + ["oss", "team", "cloud", "enterprise"] + ); + + const expectedErrors = []; + + assert.equal(getErrors(result), expectedErrors); +}); + +Suite("returns no errors on a Tabs component with no scopes", () => { + const value = ` +This is a paragraph. + + + +These instructions apply to Enterprise Cloud and Team. + + +These instructions apply to Enterprise. + + +These instructions apply to OSS. + + +`; + const result = transformer( + { + value, + path: "/content/4.0/docs/pages/filename.mdx", + }, + ["oss", "team", "cloud", "enterprise"] + ); + + const expectedErrors = []; + + assert.equal(getErrors(result), expectedErrors); +}); +Suite("returns no errors on a valid non-Tabs scoped component", () => { + const value = ` +This is a paragraph. + + + + +`; + const result = transformer( + { + value, + path: "/content/4.0/docs/pages/filename.mdx", + }, + ["oss", "team", "cloud", "enterprise"] + ); + + const expectedErrors = []; + + assert.equal(getErrors(result), expectedErrors); +}); + +Suite("catches Tabs components with missing scopes", () => { + const value = ` +This is a paragraph. + + + +These instructions apply to Enterprise Cloud and Team. + + +These instructions apply to Enterprise. + + +`; + const result = transformer( + { + value, + path: "/content/4.0/docs/pages/filename.mdx", + }, + ["oss", "team", "cloud", "enterprise", "free"] + ); + + const expectedErrors = [ + 'The page is configured for scopes "oss,team,cloud,enterprise,free", but the "oss" scope is missing from a Tabs component. Either fix the Tabs component or adjust the forScopes setting for the page in docs/config.json.', + 'The page is configured for scopes "oss,team,cloud,enterprise,free", but the "free" scope is missing from a Tabs component. Either fix the Tabs component or adjust the forScopes setting for the page in docs/config.json.', + ]; + + assert.equal(getErrors(result), expectedErrors); +}); + +Suite("catches Tabs components with an excess scope", () => { + const value = ` +This is a paragraph. + + + +These instructions apply to Enterprise Cloud and Team. + + +These instructions apply to OSS. + + +`; + const result = transformer( + { + value, + path: "/content/4.0/docs/pages/filename.mdx", + }, + ["oss", "team"] + ); + + const expectedErrors = [ + 'The page is configured for scopes "oss,team", but the TabItem component supports the "free" scope. Either fix the TabItem component or adjust the forScopes setting for the page in docs/config.json.', + ]; + + assert.equal(getErrors(result), expectedErrors); +}); + +// Note that there's currently no intention of catching non-Tabs scoped +// components with missing scopes, since it's non-trivial to determine what +// constitutes the range of components in which a scope is "missing". If a page +// includes a ScopedBlock for "enterprise", for example, it's +// difficult/impossible to determine if the page should also include +// ScopedBlocks for "cloud", "oss", and "team". +Suite("catches non-Tabs scoped components with an excess scope", () => { + const value = ` +This is a paragraph. + + +`; + const result = transformer( + { + value, + path: "/content/4.0/docs/pages/filename.mdx", + }, + ["oss", "cloud"] + ); + + const expectedErrors = [ + 'The page is configured for scopes "oss,cloud", but the ScopedComponent component supports the "free" scope. Either fix the ScopedComponent component or adjust the forScopes setting for the page in docs/config.json.', + ]; + + assert.equal(getErrors(result), expectedErrors); +}); + +Suite.run();