From bebff6dc4c6bd179f7c98667c6ec9fd21a926080 Mon Sep 17 00:00:00 2001 From: tansawit Date: Sun, 22 Feb 2026 17:43:12 +0700 Subject: [PATCH] fix: prevent mdx block collapsing under prettier --- .github/workflows/prettier.yml | 7 + .husky/.gitignore | 3 + .husky/pre-commit | 3 + README.md | 5 + STYLE_GUIDE.md | 54 ++++ package.json | 3 +- scripts/check-mdx-jsx-block-spacing.mjs | 318 ++++++++++++++++++++++++ 7 files changed, 392 insertions(+), 1 deletion(-) create mode 100644 .husky/.gitignore create mode 100755 .husky/pre-commit create mode 100644 STYLE_GUIDE.md create mode 100644 scripts/check-mdx-jsx-block-spacing.mjs diff --git a/.github/workflows/prettier.yml b/.github/workflows/prettier.yml index b7fd5aa..cba9db3 100644 --- a/.github/workflows/prettier.yml +++ b/.github/workflows/prettier.yml @@ -14,6 +14,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Use Node uses: actions/setup-node@v4 @@ -25,5 +27,10 @@ jobs: - name: Install deps run: npm ci + - name: MDX spacing check + run: | + git fetch origin "${{ github.base_ref }}" --depth=1 + npm run lint:mdx-spacing -- --diff "origin/${{ github.base_ref }}...HEAD" + - name: Prettier check run: npm run format:check diff --git a/.husky/.gitignore b/.husky/.gitignore new file mode 100644 index 0000000..21e56b9 --- /dev/null +++ b/.husky/.gitignore @@ -0,0 +1,3 @@ +* +!pre-commit +!.gitignore diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000..0b80990 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,3 @@ +#!/usr/bin/env sh + +npm run lint:mdx-spacing -- --staged diff --git a/README.md b/README.md index bf1d38c..1d7883f 100644 --- a/README.md +++ b/README.md @@ -35,3 +35,8 @@ to the default branch. Find the link to install on your dashboard. - Mintlify dev isn't running - Run `mintlify install` it'll re-install dependencies. - Page loads as a 404 - Make sure you are running in a folder with `mint.json` + +### Writing Conventions + +- Follow `STYLE_GUIDE.md` for MDX formatting rules, especially markdown block + spacing inside JSX components. diff --git a/STYLE_GUIDE.md b/STYLE_GUIDE.md new file mode 100644 index 0000000..4596d3d --- /dev/null +++ b/STYLE_GUIDE.md @@ -0,0 +1,54 @@ +# MDX Authoring Style Guide + +## Markdown Inside JSX Components + +When writing markdown inside ``, ``, or ``, always place +an empty line before and after block markdown content. + +This keeps nested content parsed as block markdown in MDX; without these blank +lines, Prettier can flatten lists, tables, and code blocks into a single line. + +Block markdown content includes: + +- fenced code blocks +- lists +- tables +- blockquotes +- headings + +### Bad + +```mdx +- item one - item two +``` + +````mdx + + +```bash +echo hello +``` + + +```` + +### Good + +```mdx + + +- item one +- item two + + +``` + +````mdx + + +```bash +echo hello +``` + + +```` diff --git a/package.json b/package.json index af7dd39..4dcfbb1 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ }, "scripts": { "format": "prettier --write .", - "format:check": "prettier --check ." + "format:check": "prettier --check .", + "lint:mdx-spacing": "node scripts/check-mdx-jsx-block-spacing.mjs" } } diff --git a/scripts/check-mdx-jsx-block-spacing.mjs b/scripts/check-mdx-jsx-block-spacing.mjs new file mode 100644 index 0000000..14b8905 --- /dev/null +++ b/scripts/check-mdx-jsx-block-spacing.mjs @@ -0,0 +1,318 @@ +#!/usr/bin/env node + +import { execFileSync } from 'node:child_process' +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs' +import path from 'node:path' + +const ROOT_DIR = process.cwd() +const TARGET_COMPONENTS = new Set(['Step', 'Tab', 'Accordion']) + +function parseArgs(argv) { + const parsed = { + staged: false, + diffRange: null, + explicitFiles: [], + } + + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i] + + if (arg === '--staged') { + parsed.staged = true + continue + } + + if (arg === '--diff') { + const range = argv[i + 1] + if (!range) { + throw new Error('Missing value for --diff') + } + parsed.diffRange = range + i += 1 + continue + } + + parsed.explicitFiles.push(arg) + } + + return parsed +} + +function listMdxFilesRecursive(dir) { + const entries = readdirSync(dir) + const files = [] + + for (const entry of entries) { + if (entry === 'node_modules' || entry === '.git') { + continue + } + + const fullPath = path.join(dir, entry) + const stats = statSync(fullPath) + + if (stats.isDirectory()) { + files.push(...listMdxFilesRecursive(fullPath)) + continue + } + + if (fullPath.endsWith('.mdx')) { + files.push(fullPath) + } + } + + return files +} + +function listMdxFilesFromGitDiff({ staged, diffRange }) { + const args = ['diff'] + + if (staged) { + args.push('--cached') + } + + args.push('--name-only', '--diff-filter=ACM') + + if (diffRange) { + args.push(diffRange) + } + + args.push('--', '*.mdx') + + const output = execFileSync('git', args, { + cwd: ROOT_DIR, + encoding: 'utf8', + }).trim() + + if (!output) { + return [] + } + + return output + .split('\n') + .map((relativePath) => path.resolve(ROOT_DIR, relativePath)) + .filter((filePath) => filePath.endsWith('.mdx') && existsSync(filePath)) +} + +function resolveTargetFiles({ staged, diffRange, explicitFiles }) { + if (explicitFiles.length > 0) { + return explicitFiles + .map((filePath) => path.resolve(ROOT_DIR, filePath)) + .filter((filePath) => filePath.endsWith('.mdx') && existsSync(filePath)) + } + + if (staged || diffRange) { + return listMdxFilesFromGitDiff({ staged, diffRange }) + } + + return listMdxFilesRecursive(ROOT_DIR) +} + +function getComponentRegions(lines) { + const regions = [] + const stack = [] + const tokenRegex = /<\/?(Step|Tab|Accordion)\b[^>]*>/g + + for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) { + const line = lines[lineIndex] + const tokens = [...line.matchAll(tokenRegex)] + + for (const token of tokens) { + const tag = token[1] + const rawToken = token[0] + const isClose = rawToken.startsWith('') + + if (!TARGET_COMPONENTS.has(tag) || isSelfClosing) { + continue + } + + if (!isClose) { + stack.push({ tag, openLine: lineIndex }) + continue + } + + let matchIndex = -1 + for (let i = stack.length - 1; i >= 0; i -= 1) { + if (stack[i].tag === tag) { + matchIndex = i + break + } + } + + if (matchIndex === -1) { + continue + } + + const open = stack.splice(matchIndex, 1)[0] + const start = open.openLine + 1 + const end = lineIndex - 1 + if (start <= end) { + regions.push({ tag, start, end }) + } + } + } + + return regions +} + +function isFenceDelimiter(line) { + return /^\s*```/.test(line) +} + +function isMarkdownBlockLine(line) { + return ( + /^\s*\|.*\|\s*$/.test(line) || + /^\s*([-*+]|\d+\.)\s+/.test(line) || + /^\s*>\s+/.test(line) || + /^\s*#{1,6}\s+\S/.test(line) + ) +} + +function isPotentiallyUnsafeIndentedBlock(lines, block) { + if (block.start < 0 || block.start >= lines.length) { + return false + } + + return /^\s{4,}\S/.test(lines[block.start]) +} + +function findBlocksInRegion(lines, region) { + const blocks = [] + let insideFence = false + let fenceStart = -1 + let groupedStart = -1 + + const flushGroupedBlock = (endIndex) => { + if (groupedStart !== -1) { + blocks.push({ start: groupedStart, end: endIndex }) + groupedStart = -1 + } + } + + for (let lineIndex = region.start; lineIndex <= region.end; lineIndex += 1) { + const line = lines[lineIndex] + + if (isFenceDelimiter(line)) { + flushGroupedBlock(lineIndex - 1) + + if (!insideFence) { + insideFence = true + fenceStart = lineIndex + } else { + insideFence = false + blocks.push({ start: fenceStart, end: lineIndex }) + fenceStart = -1 + } + continue + } + + if (insideFence) { + continue + } + + if (isMarkdownBlockLine(line)) { + if (groupedStart === -1) { + groupedStart = lineIndex + } + continue + } + + flushGroupedBlock(lineIndex - 1) + } + + if (insideFence && fenceStart !== -1) { + blocks.push({ start: fenceStart, end: region.end }) + } + + if (groupedStart !== -1) { + blocks.push({ start: groupedStart, end: region.end }) + } + + return blocks +} + +function hasBlankBoundary(lines, block, region) { + const hasBlankBefore = + block.start - 1 >= region.start && lines[block.start - 1].trim() === '' + const hasBlankAfter = + block.end + 1 <= region.end && lines[block.end + 1].trim() === '' + + return { hasBlankBefore, hasBlankAfter } +} + +function analyzeFile(filePath) { + const lines = readFileSync(filePath, 'utf8').split(/\r?\n/) + const regions = getComponentRegions(lines) + const issues = [] + + for (const region of regions) { + const blocks = findBlocksInRegion(lines, region) + + for (const block of blocks) { + if (!isPotentiallyUnsafeIndentedBlock(lines, block)) { + continue + } + + const { hasBlankBefore, hasBlankAfter } = hasBlankBoundary( + lines, + block, + region, + ) + + if (hasBlankBefore && hasBlankAfter) { + continue + } + + const reasons = [] + if (!hasBlankBefore) { + reasons.push('missing blank line before block markdown') + } + if (!hasBlankAfter) { + reasons.push('missing blank line after block markdown') + } + + issues.push({ + lineNumber: block.start + 1, + message: `${reasons.join(' and ')} inside <${region.tag}>`, + }) + } + } + + return issues +} + +function main() { + const args = parseArgs(process.argv.slice(2)) + const targetFiles = resolveTargetFiles(args) + + if (targetFiles.length === 0) { + console.log('No MDX files to check.') + process.exit(0) + } + + const allIssues = [] + for (const filePath of targetFiles) { + const issues = analyzeFile(filePath) + for (const issue of issues) { + allIssues.push({ filePath, ...issue }) + } + } + + if (allIssues.length === 0) { + console.log('MDX JSX block spacing check passed.') + process.exit(0) + } + + console.error('MDX JSX block spacing violations found:\n') + for (const issue of allIssues) { + const relativePath = path.relative(ROOT_DIR, issue.filePath) + console.error(`${relativePath}:${issue.lineNumber} ${issue.message}`) + } + + console.error( + '\nFix: add an empty line before and after indented markdown block content inside Step/Tab/Accordion.', + ) + process.exit(1) +} + +main()