Skip to content
This repository was archived by the owner on Jan 8, 2025. It is now read-only.
Closed
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
17 changes: 16 additions & 1 deletion .remarkrc.mjs
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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;
},
],
],
};

Expand Down
9 changes: 8 additions & 1 deletion server/docs-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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) {
Expand Down
128 changes: 128 additions & 0 deletions server/remark-lint-scopes.ts
Original file line number Diff line number Diff line change
@@ -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
);
});
}
}
});
}
);
188 changes: 188 additions & 0 deletions uvu-tests/remark-lint-scopes.test.ts
Original file line number Diff line number Diff line change
@@ -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.

<Tabs>
<TabItem scope={["cloud", "team"]}>
These instructions apply to Enterprise Cloud and Team.
</TabItem>
<TabItem scope="enterprise">
These instructions apply to Enterprise.
</TabItem>
<TabItem scope="oss">
These instructions apply to OSS.
</TabItem>
</Tabs>
`;
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.

<Tabs>
<TabItem label="label1">
These instructions apply to Enterprise Cloud and Team.
</TabItem>
<TabItem label="label2">
These instructions apply to Enterprise.
</TabItem>
<TabItem label="label3">
These instructions apply to OSS.
</TabItem>
</Tabs>
`;
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.

<ScopedComponent scope="team" />

<ScopedComponent scope="enterprise" />
`;
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.

<Tabs>
<TabItem scope={["team", "cloud"]}>
These instructions apply to Enterprise Cloud and Team.
</TabItem>
<TabItem scope="enterprise">
These instructions apply to Enterprise.
</TabItem>
</Tabs>
`;
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.

<Tabs>
<TabItem scope={["oss", "team"]}>
These instructions apply to Enterprise Cloud and Team.
</TabItem>
<TabItem scope="free">
These instructions apply to OSS.
</TabItem>
</Tabs>
`;
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.

<ScopedComponent scope={["cloud", "free"]} />
`;
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();