diff --git a/.remarkrc.mjs b/.remarkrc.mjs index cf5f096e..1f85dee5 100644 --- a/.remarkrc.mjs +++ b/.remarkrc.mjs @@ -1,6 +1,7 @@ import { resolve } from "path"; import remarkVariables from "./.remark-build/server/remark-variables.mjs"; import remarkIncludes from "./.remark-build/server/remark-includes.mjs"; +import { remarkLintAgentFollowability } from "./.remark-build/server/lint-agent-followability.mjs"; import remarkNoH1 from "./.remark-build/server/remark-no-h1.mjs"; import { remarkLintTeleportDocsLinks } from "./.remark-build/server/lint-teleport-docs-links.mjs"; import { remarkLintFrontmatter } from "./.remark-build/server/lint-frontmatter.mjs"; @@ -76,6 +77,7 @@ const configLint = { }, }, ], + remarkLintAgentFollowability, remarkNoH1, // validate-links must be run after remarkVariables since some links // include variables in their references, e.g., diff --git a/server/lint-agent-followability.test.ts b/server/lint-agent-followability.test.ts new file mode 100644 index 00000000..bea33d35 --- /dev/null +++ b/server/lint-agent-followability.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, test } from "@jest/globals"; +import { VFile } from "vfile"; +import { remark } from "remark"; +import mdx from "remark-mdx"; +import remarkFrontmatter from "remark-frontmatter"; +import { remarkLintAgentFollowability } from "./lint-agent-followability"; + +const getReasons = (value: string) => { + return remark() + .use(mdx as any) + .use(remarkLintAgentFollowability as any) + .processSync(new VFile({ value, path: "mypath.mdx" }) as any) + .messages.map((m) => m.reason); +}; + +describe("server/lint-agent-followability", () => { + describe('linting "How it works" H2s in how-to guides', () => { + interface testCase { + description: string; + input: string; + expected: Array; + } + + const testCases: Array = [ + { + description: `no h2 heading with a step`, + input: `--- +title: Docs Page +description: Provides instructions about a feature. +--- + +This is an introduction. + +## Prerequisites + +- A Teleport cluster + +## Installation + +This step shows you how to install Teleport. +`, + expected: [], + }, + { + description: `one step h2 with no command`, + input: `--- +title: Docs Page +description: Provides instructions about a feature. +--- + +This is an introduction. + +## Prerequisites + +- A Teleport cluster + +## Step 1/1. Use your browser to install Teleport + +This step shows you how to install Teleport. +`, + expected: [ + 'In a how-to guide, each H2 beginning "Step " must include at least one command so CLI users and AI agents can follow it. Step 1/1 has no command. Disable this warning by adding {/* lint ignore agent-followability remark-lint */} before this line.', + ], + }, + { + description: `one step h2 with command`, + input: `--- +title: Docs Page +description: Provides instructions about a feature. +--- + +This is an introduction. + +## Prerequisites + +- A Teleport cluster + +## Step 1/1. Use your browser to install Teleport + +Run the following command: + +\`\`\`code +$ curl example.com | bash +\`\`\` +`, + expected: [], + }, + { + description: `one step h2 with command in a tab item`, + input: `--- +title: Docs Page +description: Provides instructions about a feature. +--- + +This is an introduction. + +## Prerequisites + +- A Teleport cluster + +## Step 1/1. Use your browser to install Teleport + +Run the following command depending on your system: + + + + +\`\`\`code +$ curl example.com/linux-install | bash +\`\`\` + + + + +\`\`\`code +$ curl example.com/windows-install | bash +\`\`\` + + + +`, + expected: [], + }, + { + description: `one step h2 with command and a step h3 with no command`, + input: `--- +title: Docs Page +description: Provides instructions about a feature. +--- + +This is an introduction. + +## Prerequisites + +- A Teleport cluster + +## Step 1/1. Use your browser to install Teleport + +Complete the following sub-steps. + +### Step 1/2 + +Run the following command: + +\`\`\`code +$ curl example.com | bash +\`\`\` + +### Step 2/2 + +Open a browser and make sure it works. +`, + expected: [], + }, + { + description: `no command in second of three steps`, + input: `--- +title: Docs Page +description: Provides instructions about a feature. +--- + +This is an introduction. + +## Prerequisites + +- A Teleport cluster + +## Step 1/3. Run the installation command + +Run the following command: + +\`\`\`code +$ curl example.com | bash +\`\`\` + +## Step 2/3. Check that installation succeeded + +Run the \`version\` command to make sure you did it. + +## Step 3/3. Run a command + +\`\`\`code +$ mycommand help +\`\`\` +`, + expected: [ + 'In a how-to guide, each H2 beginning "Step " must include at least one command so CLI users and AI agents can follow it. Step 2/3 has no command. Disable this warning by adding {/* lint ignore agent-followability remark-lint */} before this line.', + ], + }, + ]; + + test.each(testCases)("$description", (tc) => { + expect(getReasons(tc.input)).toEqual(tc.expected); + }); + }); +}); diff --git a/server/lint-agent-followability.ts b/server/lint-agent-followability.ts new file mode 100644 index 00000000..2ab2105d --- /dev/null +++ b/server/lint-agent-followability.ts @@ -0,0 +1,67 @@ +import { lintRule } from "unified-lint-rule"; +import { visit } from "unist-util-visit"; +import { VFile } from "vfile"; +import type { Node } from "unist"; +import type { Heading, Parent, Code, Text } from "mdast"; + +const stepRE = new RegExp(/^Step [0-9]+\/[0-9]+/); + +function emitUnfollowableMessage( + current: Heading, + headingText: string, + vFile: VFile, +) { + vFile.message( + `In a how-to guide, each H2 beginning "Step " must include at least one command so CLI users and AI agents can follow it. ${headingText} has no command. Disable this warning by adding {/* lint ignore agent-followability remark-lint */} before this line.`, + ); +} + +export const remarkLintAgentFollowability = lintRule( + "remark-lint:agent-followability", + (root: Node, vfile: VFile) => { + let currentStepH2: Heading | undefined; + let currentStepNum: string | undefined; + let hasCode = false; + + (root as Parent).children.forEach((topLevel) => { + if ( + topLevel.type == "heading" && + topLevel.depth === 2 && + topLevel.children.length > 0 + ) { + // We've reached a new H2, so check whether there was a previous one + // that was unfollowable. + if (currentStepH2 && currentStepNum && !hasCode) { + emitUnfollowableMessage(currentStepH2, currentStepNum, vfile); + } + + const val = topLevel.children[0]; + if (val.type != "text") { + return; + } + + const stepNum = stepRE.exec((val as Text).value); + if (!stepNum) { + return; + } + + currentStepH2 = topLevel as Heading; + currentStepNum = stepNum[0]; + hasCode = false; + return; + } + + visit(topLevel, "code", (code) => { + if (code.lang !== "code") { + return; + } + hasCode = true; + }); + }); + + // We're at the end of the document, so check the final H2. + if (currentStepH2 && currentStepNum && !hasCode) { + emitUnfollowableMessage(currentStepH2, currentStepNum, vfile); + } + }, +);