Skip to content
Draft
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
2 changes: 2 additions & 0 deletions .remarkrc.mjs
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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.,
Expand Down
196 changes: 196 additions & 0 deletions server/lint-agent-followability.test.ts
Original file line number Diff line number Diff line change
@@ -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<string>;
}

const testCases: Array<testCase> = [
{
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:

<Tabs>
<TabItem label="Linux">

\`\`\`code
$ curl example.com/linux-install | bash
\`\`\`

</TabItem>
<TabItem label="Windows">

\`\`\`code
$ curl example.com/windows-install | bash
\`\`\`

</TabItem>
</Tabs>
`,
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);
});
});
});
67 changes: 67 additions & 0 deletions server/lint-agent-followability.ts
Original file line number Diff line number Diff line change
@@ -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);
}
},
);
Loading