diff --git a/tools/mdx-lint/mdx-lint.mjs b/tools/mdx-lint/mdx-lint.mjs
index 936c31e..659a548 100644
--- a/tools/mdx-lint/mdx-lint.mjs
+++ b/tools/mdx-lint/mdx-lint.mjs
@@ -33,6 +33,7 @@ const ALLOWED_COMPONENTS = new Set([
"Info",
"Icon",
"Frame",
+ "CardGroup",
"Card",
"Check",
"Tabs",
@@ -41,6 +42,7 @@ const ALLOWED_COMPONENTS = new Set([
"Step",
]);
+const ALLOWED_INTRINSIC_ELEMENTS = new Set(["br"]);
const URL_ATTRIBUTE_NAMES = new Set(["href", "src", "action", "formaction"]);
function decodeHtmlEntities(input) {
@@ -81,6 +83,26 @@ function fail(node, message) {
throw new Error(`${at(node)} ${message}`);
}
+function isMdxCommentExpression(node) {
+ const value = String(node.value ?? "").trim();
+ if (!value) {
+ return false;
+ }
+
+ let rest = value;
+ while (rest) {
+ if (!rest.startsWith("/*")) {
+ return false;
+ }
+ const end = rest.indexOf("*/", 2);
+ if (end < 0) {
+ return false;
+ }
+ rest = rest.slice(end + 2).trim();
+ }
+ return true;
+}
+
function validateUrlNode(node) {
if (node.url && containsDangerousUrl(node.url)) {
fail(node, "contains a dangerous URL scheme");
@@ -121,7 +143,10 @@ function validateJsxElement(node) {
if (node.name.includes(".")) {
fail(node, `contains disallowed JSX component "${node.name}"`);
}
- if (!ALLOWED_COMPONENTS.has(node.name)) {
+ if (
+ !ALLOWED_COMPONENTS.has(node.name) &&
+ !ALLOWED_INTRINSIC_ELEMENTS.has(node.name)
+ ) {
fail(node, `contains disallowed JSX component "${node.name}"`);
}
@@ -138,6 +163,9 @@ function validateTree(tree) {
break;
case "mdxFlowExpression":
case "mdxTextExpression":
+ if (isMdxCommentExpression(node)) {
+ break;
+ }
fail(node, "contains an MDX expression");
break;
case "mdxJsxFlowElement":
diff --git a/tools/mdx-lint/mdx-lint.test.mjs b/tools/mdx-lint/mdx-lint.test.mjs
index 329a5eb..7303dfc 100644
--- a/tools/mdx-lint/mdx-lint.test.mjs
+++ b/tools/mdx-lint/mdx-lint.test.mjs
@@ -1,4 +1,5 @@
import assert from "node:assert/strict";
+import { readFile } from "node:fs/promises";
import { describe, it } from "node:test";
import { lintMdxContent } from "./mdx-lint.mjs";
@@ -11,6 +12,13 @@ async function expectInvalid(content, pattern) {
await assert.rejects(() => lintMdxContent(content), pattern);
}
+const sharedFixtures = JSON.parse(
+ await readFile(
+ new URL("./testdata/shared-mdx-validation.json", import.meta.url),
+ "utf8",
+ ),
+);
+
describe("mdx-lint policy", () => {
it("allows supported connector docs content", async () => {
await expectValid(`---
@@ -70,6 +78,12 @@ Read the public setup guide at https://example.com/docs.
await expectInvalid("{process.env.SECRET}\n", /MDX expression/);
});
+ it("allows MDX comments", async () => {
+ await expectValid(`{/* AUTO-GENERATED:START - capabilities
+ Generated from baton_capabilities.json. Do not edit manually. */}
+`);
+ });
+
it("rejects raw HTML elements", async () => {
await expectInvalid("
raw html
\n", /disallowed JSX component "div"/);
});
@@ -96,4 +110,19 @@ Read the public setup guide at https://example.com/docs.
await expectInvalid("\ufeff# Title\n", /byte order marks/);
await expectInvalid("hello\0world\n", /NUL bytes/);
});
+
+ describe("shared validation fixtures", () => {
+ for (const fixture of sharedFixtures) {
+ it(fixture.name, async () => {
+ if (fixture.valid) {
+ await expectValid(fixture.content);
+ return;
+ }
+ await expectInvalid(
+ fixture.content,
+ new RegExp(fixture.errorContains),
+ );
+ });
+ }
+ });
});
diff --git a/tools/mdx-lint/testdata/shared-mdx-validation.json b/tools/mdx-lint/testdata/shared-mdx-validation.json
new file mode 100644
index 0000000..1651673
--- /dev/null
+++ b/tools/mdx-lint/testdata/shared-mdx-validation.json
@@ -0,0 +1,100 @@
+[
+ {
+ "name": "allow dangerous scheme word in prose",
+ "valid": true,
+ "content": "\n \n In the permissions section, choose the relevant permissions:\n\n To sync (read) data:\n\n - Project: Read\n \n\n"
+ },
+ {
+ "name": "allow angle bracket placeholder in inline code",
+ "valid": true,
+ "content": "Give the app a globally unique name, such as \"c1-integration-``\".\n"
+ },
+ {
+ "name": "allow data scheme literal in inline code",
+ "valid": true,
+ "content": "Use `data:` as the literal scheme name in examples.\n"
+ },
+ {
+ "name": "allow data URL literal in fenced code",
+ "valid": true,
+ "content": "```json\n{\"tenant\":\"{tenant}\",\"url\":\"data:image/png;base64,abc\"}\n```\n"
+ },
+ {
+ "name": "allow MDX comment",
+ "valid": true,
+ "content": "{/* AUTO-GENERATED:START - capabilities\n Generated from baton_capabilities.json. Do not edit manually. */}\n"
+ },
+ {
+ "name": "allow nested indented fence placeholders",
+ "valid": true,
+ "content": " ```yaml expandable\n BATON_CLIENT_ID: \n ```\n"
+ },
+ {
+ "name": "allow tab-indented fenced content",
+ "valid": true,
+ "content": " ```json expandable\n\t{\n\t \"ok\": true\n\t}\n ```\n"
+ },
+ {
+ "name": "allow escaped angle bracket placeholder",
+ "valid": true,
+ "content": "Use \\ as the placeholder.\n"
+ },
+ {
+ "name": "reject double escaped JSX tag",
+ "valid": false,
+ "errorContains": "disallowed JSX component",
+ "content": "This is not escaped JSX: \\\\\n"
+ },
+ {
+ "name": "reject escaped fake MDX comment hiding JSX",
+ "valid": false,
+ "errorContains": "dangerous URL scheme",
+ "content": "Note: write the literal sequence \\{/* in your markdown when you need it.\n\nbad\n\nEnd of note */}.\n"
+ },
+ {
+ "name": "reject inline code fake MDX comment hiding JSX",
+ "valid": false,
+ "errorContains": "dangerous URL scheme",
+ "content": "Use `{/*` as literal syntax.\n\nbad\n\nUse `*/}` as literal syntax.\n"
+ },
+ {
+ "name": "reject mixed MDX comment expression",
+ "valid": false,
+ "errorContains": "MDX expression",
+ "content": "{/* a */ x /* b */}\n"
+ },
+ {
+ "name": "allow table line break element",
+ "valid": true,
+ "content": "| Field | Description |\n| --- | --- |\n| Values | `one`
`two` |\n"
+ },
+ {
+ "name": "allow CardGroup layout wrapper",
+ "valid": true,
+ "content": "\n\n\n"
+ },
+ {
+ "name": "reject backtick fence info string with backtick",
+ "valid": false,
+ "errorContains": "MDX expression",
+ "content": "``` `\n{process.env.REGISTRY_TOKEN}\n```\n"
+ },
+ {
+ "name": "reject markdown data URL",
+ "valid": false,
+ "errorContains": "dangerous URL scheme",
+ "content": "[image](data:image/png;base64,abc)\n"
+ },
+ {
+ "name": "reject JSX data URL attribute",
+ "valid": false,
+ "errorContains": "dangerous URL scheme",
+ "content": "\">bad\n"
+ },
+ {
+ "name": "reject encoded javascript URL",
+ "valid": false,
+ "errorContains": "dangerous URL scheme",
+ "content": "[link](javascript:alert(1))\n"
+ }
+]