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();