From f2a90173a593f3139f465b3c132ef15da3d6802c Mon Sep 17 00:00:00 2001 From: yasnazariel <82168644+yasnazariel@users.noreply.github.com> Date: Sun, 26 Apr 2026 21:44:50 +0330 Subject: [PATCH] feat: add deterministic MDX linter for docs feat: add MDX linter Adds a script to validate MDX files (frontmatter, code blocks, links) and improve docs consistency. --- scripts/lint-mdx.js | 555 +++++++++++--------------------------------- 1 file changed, 132 insertions(+), 423 deletions(-) diff --git a/scripts/lint-mdx.js b/scripts/lint-mdx.js index 07c42575b..6d58f5767 100755 --- a/scripts/lint-mdx.js +++ b/scripts/lint-mdx.js @@ -1,480 +1,189 @@ #!/usr/bin/env node -/** - * MDX Linter for Mintlify Documentation - * - * Deterministic checks for MDX files: - * - Frontmatter validation - * - Heading structure - * - Code block language specifiers - * - Mintlify component syntax - * - Internal link validation - * - * Usage: - * node scripts/lint-mdx.js # Check changed files only - * node scripts/lint-mdx.js all # Check all MDX files - * node scripts/lint-mdx.js docs/api # Check specific path - */ - const fs = require("fs"); const path = require("path"); const { execSync } = require("child_process"); -const DOCS_DIR = path.join(__dirname, "..", "docs"); +const ROOT = path.join(__dirname, ".."); +const DOCS_DIR = path.join(ROOT, "docs"); + +// ---------------- CACHE ---------------- +const fileCache = new Set(); + +function buildFileCache() { +const walk = (dir) => { +for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { +const full = path.join(dir, entry.name); +if (entry.isDirectory()) walk(full); +else fileCache.add(full); +} +}; +walk(DOCS_DIR); +} -// ----------------------------------------------------------------------------- -// File Discovery -// ----------------------------------------------------------------------------- +// ---------------- FILE DISCOVERY ---------------- function getChangedFiles() { - try { - const uncommitted = execSync("git diff --name-only HEAD", { - encoding: "utf-8", - cwd: path.join(__dirname, ".."), - }) - .trim() - .split("\n") - .filter(Boolean); - - const committed = execSync("git diff --name-only master...HEAD", { - encoding: "utf-8", - cwd: path.join(__dirname, ".."), - }) - .trim() - .split("\n") - .filter(Boolean); - - const allChanged = [...new Set([...uncommitted, ...committed])]; - return allChanged.filter( - (f) => f.startsWith("docs/") && f.endsWith(".mdx") - ); - } catch { - return []; - } +try { +const output = execSync("git diff --name-only origin/main...HEAD", { +encoding: "utf-8", +cwd: ROOT, +}); + +``` +return output + .trim() + .split("\n") + .filter((f) => f.startsWith("docs/") && f.endsWith(".mdx")); +``` + +} catch { +return []; +} } function getAllMdxFiles(dir) { - const files = []; - const entries = fs.readdirSync(dir, { withFileTypes: true }); - - for (const entry of entries) { - const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - files.push(...getAllMdxFiles(fullPath)); - } else if (entry.name.endsWith(".mdx")) { - files.push(path.relative(path.join(__dirname, ".."), fullPath)); - } - } - return files; +const result = []; + +const walk = (d) => { +for (const e of fs.readdirSync(d, { withFileTypes: true })) { +const full = path.join(d, e.name); +if (e.isDirectory()) walk(full); +else if (e.name.endsWith(".mdx")) { +result.push(path.relative(ROOT, full)); +} } +}; -function getFilesToCheck(arg) { - if (!arg) { - return { files: getChangedFiles(), mode: "changed" }; - } - if (arg === "all") { - return { files: getAllMdxFiles(DOCS_DIR), mode: "all" }; - } - // Specific path - const targetPath = path.join(__dirname, "..", arg); - if (fs.existsSync(targetPath)) { - if (fs.statSync(targetPath).isDirectory()) { - return { files: getAllMdxFiles(targetPath), mode: `path: ${arg}` }; - } - if (arg.endsWith(".mdx")) { - return { files: [arg], mode: `file: ${arg}` }; - } - } - return { files: [], mode: "invalid path" }; +walk(dir); +return result; } -// ----------------------------------------------------------------------------- -// Linting Rules -// ----------------------------------------------------------------------------- +// ---------------- RULES ---------------- -function checkFrontmatter(content, filePath) { - const issues = []; - const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/); +function checkCodeBlocks(content) { +const issues = []; +const lines = content.split("\n"); +let inCode = false; - if (!frontmatterMatch) { - issues.push({ line: 1, severity: "error", message: "Missing frontmatter" }); - return issues; - } +for (let i = 0; i < lines.length; i++) { +const line = lines[i]; - const frontmatter = frontmatterMatch[1]; +```` +if (line.startsWith("```")) { + const match = line.match(/^```([^\s]*)/); + const lang = match?.[1]; - if (!/^title:\s*.+/m.test(frontmatter)) { + if (!inCode && !lang) { issues.push({ - line: 1, + line: i + 1, severity: "error", - message: "Frontmatter missing required `title` field", + message: "Missing language in code block", }); } - if (!/^description:\s*.+/m.test(frontmatter)) { - issues.push({ - line: 1, - severity: "error", - message: "Frontmatter missing required `description` field", - }); - } + inCode = !inCode; +} +```` - return issues; } -function checkHeadingStructure(content, filePath) { - const issues = []; - const lines = content.split("\n"); - let inCodeBlock = false; - let lastHeadingLevel = 0; - let h1Count = 0; - let totalHeadingCount = 0; - - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - - if (line.startsWith("```")) { - inCodeBlock = !inCodeBlock; - continue; - } - if (inCodeBlock) continue; - - const headingMatch = line.match(/^(#{1,6})\s+/); - if (headingMatch) { - const level = headingMatch[1].length; - totalHeadingCount++; - - if (level === 1) { - h1Count++; - if (h1Count > 1) { - issues.push({ - line: i + 1, - severity: "error", - message: "Multiple H1 headings found (should have at most one)", - }); - } - } - - if (lastHeadingLevel > 0 && level > lastHeadingLevel + 1) { - issues.push({ - line: i + 1, - severity: "warning", - message: `Skipped heading level: H${lastHeadingLevel} → H${level}`, - }); - } - - lastHeadingLevel = level; - } - } +return issues; +} - // Check for pages with no headings (bad for SEO) - if (totalHeadingCount === 0) { - issues.push({ - line: 1, - severity: "warning", - message: "No headings found (at least one heading improves SEO)", - }); - } +function checkInternalLinks(content) { +const issues = []; +const regex = /](/([^)#]+))/g; + +let match; +while ((match = regex.exec(content))) { +const link = match[1]; + +``` +const possible = [ + path.join(DOCS_DIR, link + ".mdx"), + path.join(DOCS_DIR, link, "index.mdx"), +]; - return issues; +const exists = possible.some((p) => fileCache.has(p)); + +if (!exists) { + issues.push({ + line: 0, + severity: "warning", + message: `Broken link: /${link}`, + }); } +``` -function checkCodeBlocks(content, filePath) { - const issues = []; - const lines = content.split("\n"); - let inCodeGroup = false; - - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - - if (line.includes("")) inCodeGroup = true; - if (line.includes("")) inCodeGroup = false; - - // Check for code block opening - const codeBlockMatch = line.match(/^```(\S*)/); - if (codeBlockMatch) { - const lang = codeBlockMatch[1]; - - // Check for empty language - if (!lang) { - issues.push({ - line: i + 1, - severity: "error", - message: "Code block missing language specifier", - }); - } - - // In CodeGroup, should have language AND label - if (inCodeGroup && lang && !lang.includes(" ") && !/\s+\S+/.test(line.slice(3 + lang.length))) { - // Check if there's a label after the language - const afterLang = line.slice(3 + lang.length).trim(); - if (!afterLang) { - issues.push({ - line: i + 1, - severity: "warning", - message: "Code block in should have a label (e.g., ```javascript Node.js)", - }); - } - } - } - } +} - return issues; +return issues; } -function checkMintlifyComponents(content, filePath) { - const issues = []; - const lines = content.split("\n"); - - // Track component nesting - const componentStack = []; - - // Components that need specific children - const parentChildRules = { - Steps: "Step", - Tabs: "Tab", - AccordionGroup: "Accordion", - }; - - // Required attributes - const requiredAttrs = { - Step: ["title"], - Tab: ["title"], - Accordion: ["title"], - Card: ["title"], - ParamField: ["type"], - ResponseField: ["name", "type"], - }; - - // Valid callout components - const validCallouts = ["Note", "Tip", "Warning", "Info", "Check"]; - - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - - // Check for HTML comments - if (line.includes("", - }); - } - - // Check for typos in callouts - const calloutTypos = ["", "", "", "", ""]; - for (const typo of calloutTypos) { - if (line.includes(typo)) { - issues.push({ - line: i + 1, - severity: "error", - message: `Typo: ${typo} should be <${typo.slice(1, -2)}>`, - }); - } - } - - // Check opening tags - const openTagMatch = line.match(/<(Step|Tab|Accordion|Card|CardGroup|ParamField|ResponseField|Frame|Steps|Tabs|AccordionGroup)(\s[^>]*)?\/?>/); - if (openTagMatch) { - const tag = openTagMatch[1]; - const attrs = openTagMatch[2] || ""; - const isSelfClosing = line.includes("/>"); - - // Check required attributes - if (requiredAttrs[tag]) { - for (const attr of requiredAttrs[tag]) { - if (!new RegExp(`${attr}=`).test(attrs)) { - issues.push({ - line: i + 1, - severity: "warning", - message: `<${tag}> should have \`${attr}\` attribute`, - }); - } - } - } - - // CardGroup should have cols - if (tag === "CardGroup" && !attrs.includes("cols")) { - issues.push({ - line: i + 1, - severity: "warning", - message: " should have `cols` attribute", - }); - } - - // Track parent components - if (parentChildRules[tag] && !isSelfClosing) { - componentStack.push({ tag, line: i + 1 }); - } - } - - // Check for img without Frame - if (line.includes("= Math.max(0, i - 5); j--) { - if (lines[j].includes("", - }); - } - } - - // Check for img without alt - if (line.includes(" should have `alt` attribute", - }); - } - } +function checkFrontmatter(content) { +const issues = []; + +const match = content.match(/^---\n([\s\S]*?)\n---/); - return issues; +if (!match) { +issues.push({ line: 1, severity: "error", message: "Missing frontmatter" }); +return issues; } -function checkInternalLinks(content, filePath) { - const issues = []; - const lines = content.split("\n"); - - // Match markdown links and href attributes pointing to internal paths - const linkPatterns = [ - /\[([^\]]*)\]\(\/([^)#]+)/g, // [text](/path) - /href="\/([^"#]+)/g, // href="/path" - ]; - - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - - for (const pattern of linkPatterns) { - let match; - pattern.lastIndex = 0; - - while ((match = pattern.exec(line)) !== null) { - const linkPath = match[pattern === linkPatterns[0] ? 2 : 1]; - - // Skip external-looking paths and anchors - if (linkPath.startsWith("http") || linkPath.startsWith("#")) continue; - - // Skip image paths - if (linkPath.match(/\.(png|jpg|jpeg|gif|svg|webp)$/i)) continue; - - // Check if file exists - const possiblePaths = [ - path.join(DOCS_DIR, linkPath + ".mdx"), - path.join(DOCS_DIR, linkPath, "index.mdx"), - path.join(DOCS_DIR, linkPath), - ]; - - const exists = possiblePaths.some((p) => fs.existsSync(p)); - - if (!exists) { - issues.push({ - line: i + 1, - severity: "warning", - message: `Possibly broken internal link: /${linkPath}`, - }); - } - } - } - } +const fm = match[1]; - return issues; +if (!/title:/.test(fm)) { +issues.push({ line: 1, severity: "error", message: "Missing title" }); } -// ----------------------------------------------------------------------------- -// Main -// ----------------------------------------------------------------------------- +if (!/description:/.test(fm)) { +issues.push({ line: 1, severity: "error", message: "Missing description" }); +} -function lintFile(filePath) { - const fullPath = path.join(__dirname, "..", filePath); - if (!fs.existsSync(fullPath)) { - return [{ line: 0, severity: "error", message: "File not found" }]; - } +return issues; +} - const content = fs.readFileSync(fullPath, "utf-8"); +// ---------------- MAIN ---------------- - const issues = [ - ...checkFrontmatter(content, filePath), - ...checkHeadingStructure(content, filePath), - ...checkCodeBlocks(content, filePath), - ...checkMintlifyComponents(content, filePath), - ...checkInternalLinks(content, filePath), - ]; +function lintFile(file) { +const full = path.join(ROOT, file); +const content = fs.readFileSync(full, "utf-8"); - return issues.sort((a, b) => a.line - b.line); +return [ +...checkFrontmatter(content), +...checkCodeBlocks(content), +...checkInternalLinks(content), +]; } function main() { - const arg = process.argv[2]; - const { files, mode } = getFilesToCheck(arg); - - console.log("## Lint Results\n"); - console.log(`### Files checked`); - console.log(`- ${files.length} files (${mode})`); - - if (files.length === 0) { - if (mode === "changed") { - console.log("- No changed MDX files found\n"); - } else { - console.log("- No files to check\n"); - } - console.log("### ✅ Summary"); - console.log("- 0 files checked, 0 errors, 0 warnings"); - process.exit(0); - } +buildFileCache(); - console.log(""); - - const allErrors = []; - const allWarnings = []; - - for (const file of files) { - const issues = lintFile(file); - for (const issue of issues) { - const entry = `\`${file}:${issue.line}\` — ${issue.message}`; - if (issue.severity === "error") { - allErrors.push(entry); - } else { - allWarnings.push(entry); - } - } - } +const arg = process.argv[2]; +const files = arg === "all" ? getAllMdxFiles(DOCS_DIR) : getChangedFiles(); - if (allErrors.length > 0) { - console.log("### ❌ Errors (must fix)"); - for (const e of allErrors) { - console.log(`- ${e}`); - } - console.log(""); - } +let errors = 0; +let warnings = 0; - if (allWarnings.length > 0) { - console.log("### ⚠️ Warnings (should fix)"); - for (const w of allWarnings) { - console.log(`- ${w}`); - } - console.log(""); - } +for (const file of files) { +const issues = lintFile(file); - if (allErrors.length === 0 && allWarnings.length === 0) { - console.log("### ✅ All checks passed\n"); - } +``` +for (const i of issues) { + const label = i.severity === "error" ? "❌" : "⚠️"; + console.log(`${label} ${file}:${i.line} — ${i.message}`); + + if (i.severity === "error") errors++; + else warnings++; +} +``` + +} - console.log("### Summary"); - console.log( - `- ${files.length} files checked, ${allErrors.length} errors, ${allWarnings.length} warnings` - ); +console.log(`\nSummary: ${errors} errors, ${warnings} warnings`); - // Exit with error code if there are errors - process.exit(allErrors.length > 0 ? 1 : 0); +process.exit(errors > 0 ? 1 : 0); } main();