From 39c379b2c4513d1f6359456009a0d730da86ae24 Mon Sep 17 00:00:00 2001 From: Steve Gontzes Date: Mon, 18 May 2026 20:33:58 -0400 Subject: [PATCH] Add shared MDX validation fixtures --- tools/mdx-lint/mdx-lint.mjs | 30 +++++- tools/mdx-lint/mdx-lint.test.mjs | 29 +++++ .../testdata/shared-mdx-validation.json | 100 ++++++++++++++++++ 3 files changed, 158 insertions(+), 1 deletion(-) create mode 100644 tools/mdx-lint/testdata/shared-mdx-validation.json 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" + } +]