Skip to content
Open
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
281 changes: 281 additions & 0 deletions __tests__/lib/mdxish-snake-case.test.ts
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be located in __tests__/lib/mdxish?

The rest of the test files in __tests__/lib map directly to a function exported from lib.

Original file line number Diff line number Diff line change
@@ -0,0 +1,281 @@
import type { Element } from 'hast';

import { mdxish } from '../../lib/mdxish';
import { type RMDXModule } from '../../types';

const stubModule: RMDXModule = {
default: () => null,
Toc: null,
toc: [],
};

const makeComponents = (...names: string[]) =>
names.reduce<Record<string, RMDXModule>>((acc, name) => {
acc[name] = stubModule;
return acc;
}, {});

describe('mdxish snake_case component integration', () => {
describe('basic rendering', () => {
it('should render snake_case component as HAST element', () => {
const doc = '<Snake_case />';
const components = makeComponents('Snake_case');

const hast = mdxish(doc, { components });

const component = hast.children.find(child => child.type === 'element' && child.tagName === 'Snake_case');
expect(component).toBeDefined();
expect(component?.type).toBe('element');
expect(component?.tagName).toBe('Snake_case');
});

it('should render component with multiple underscores', () => {
const doc = '<Multiple_Underscore_Component />';
const components = makeComponents('Multiple_Underscore_Component');

const hast = mdxish(doc, { components });

const component = hast.children.find(
child => child.type === 'element' && child.tagName === 'Multiple_Underscore_Component',
);
expect(component).toBeDefined();
});

it('should remove undefined snake_case component', () => {
const doc = '<Undefined_Component />';
const hast = mdxish(doc);

const component = hast.children.find(
child => child.type === 'element' && child.tagName === 'Undefined_Component',
);
expect(component).toBeUndefined();
});
});

describe('components with content', () => {
it('should render snake_case component with text content', () => {
const doc = `<Snake_case>
Simple text content
</Snake_case>`;

const components = makeComponents('Snake_case');

const hast = mdxish(doc, { components });

const component = hast.children.find(child => child.type === 'element' && child.tagName === 'Snake_case');
expect(component).toBeDefined();
expect(component?.type).toBe('element');

const elementNode = component as Element;
expect(elementNode.children.length).toBeGreaterThan(0);
});

it('should render snake_case component with markdown content', () => {
const doc = `<Snake_case>

# Heading

Some **bold** and *italic* text.

</Snake_case>`;

const components = makeComponents('Snake_case');

const hast = mdxish(doc, { components });

const component = hast.children.find(child => child.type === 'element' && child.tagName === 'Snake_case');
expect(component).toBeDefined();

const elementNode = component as Element;
expect(elementNode.children.length).toBeGreaterThan(0);
});
});

describe('components with attributes', () => {
it('should preserve string attributes', () => {
const doc = '<Snake_case theme="info" id="test-id" />';
const components = makeComponents('Snake_case');

const hast = mdxish(doc, { components });

const component = hast.children.find(child => child.type === 'element' && child.tagName === 'Snake_case');
expect(component).toBeDefined();
expect(component?.type).toBe('element');

const elementNode = component as Element;
expect(elementNode.properties?.theme).toBe('info');
expect(elementNode.properties?.id).toBe('test-id');
});

it('should preserve boolean attributes', () => {
const doc = '<Snake_case empty />';
const components = makeComponents('Snake_case');

const hast = mdxish(doc, { components });

const component = hast.children.find(child => child.type === 'element' && child.tagName === 'Snake_case');
expect(component).toBeDefined();
expect(component?.type).toBe('element');

const elementNode = component as Element;
expect(elementNode.properties?.empty).toBeDefined();
});
});

describe('multiple components', () => {
it('should render multiple instances of same snake_case component', () => {
const doc = `<Snake_case />

<Snake_case />

<Snake_case />`;

const components = makeComponents('Snake_case');

const hast = mdxish(doc, { components });

const componentsFound = hast.children.filter(child => child.type === 'element' && child.tagName === 'Snake_case');
expect(componentsFound).toHaveLength(3);
});

it('should render multiple different snake_case components', () => {
const doc = `<First_Component />

<Second_Component />

<First_Component />`;

const components = makeComponents('First_Component', 'Second_Component');

const hast = mdxish(doc, { components });

const firstComponents = hast.children.filter(
child => child.type === 'element' && child.tagName === 'First_Component',
);
const secondComponents = hast.children.filter(
child => child.type === 'element' && child.tagName === 'Second_Component',
);

expect(firstComponents).toHaveLength(2);
expect(secondComponents).toHaveLength(1);
});
});

describe('nested components', () => {
it('should handle nested snake_case components', () => {
const doc = `<Outer_Component>

<Inner_Component />

</Outer_Component>`;

const components = makeComponents('Outer_Component', 'Inner_Component');

const hast = mdxish(doc, { components });

const outerComponent = hast.children.find(
child => child.type === 'element' && child.tagName === 'Outer_Component',
);
expect(outerComponent).toBeDefined();
expect(outerComponent?.type).toBe('element');

const outerElement = outerComponent as Element;
const innerComponent = outerElement.children.find(
child => child.type === 'element' && (child as Element).tagName === 'Inner_Component',
);
expect(innerComponent).toBeDefined();
});
});

describe('mixed component types', () => {
it('should handle snake_case alongside PascalCase components', () => {
const doc = `<Snake_case />

<PascalCase />`;

const components = makeComponents('Snake_case', 'PascalCase');

const hast = mdxish(doc, { components });

const snakeComponent = hast.children.find(child => child.type === 'element' && child.tagName === 'Snake_case');
const pascalComponent = hast.children.find(child => child.type === 'element' && child.tagName === 'PascalCase');

expect(snakeComponent).toBeDefined();
expect(pascalComponent).toBeDefined();
});

it('should handle snake_case alongside markdown', () => {
const doc = `# Main Heading

Some regular markdown text.

<Snake_case />

More markdown after the component.`;

const components = makeComponents('Snake_case');

const hast = mdxish(doc, { components });

const heading = hast.children.find(child => child.type === 'element' && child.tagName === 'h1');
const component = hast.children.find(child => child.type === 'element' && child.tagName === 'Snake_case');
const paragraphs = hast.children.filter(child => child.type === 'element' && child.tagName === 'p');

expect(heading).toBeDefined();
expect(component).toBeDefined();
expect(paragraphs.length).toBeGreaterThan(0);
});
});

describe('edge cases', () => {
it('should handle consecutive underscores', () => {
const doc = '<Component__Double />';
const components = makeComponents('Component__Double');

const hast = mdxish(doc, { components });

const component = hast.children.find(child => child.type === 'element' && child.tagName === 'Component__Double');
expect(component).toBeDefined();
});

it('should NOT transform lowercase snake_case tags', () => {
const doc = '<snake_case />\n\n<Snake_case />';
const components = makeComponents('Snake_case');

const hast = mdxish(doc, { components });

const upperComponent = hast.children.find(child => child.type === 'element' && child.tagName === 'Snake_case');
expect(upperComponent).toBeDefined();
});
});

describe('regression tests', () => {
it('should still render PascalCase components correctly', () => {
const doc = '<MyComponent />';
const components = makeComponents('MyComponent');

const hast = mdxish(doc, { components });

const component = hast.children.find(child => child.type === 'element' && child.tagName === 'MyComponent');
expect(component).toBeDefined();
});

it('should still render kebab-case components correctly', () => {
const doc = '<my-component />';
const components = makeComponents('my-component');

const hast = mdxish(doc, { components });

const component = hast.children.find(child => child.type === 'element' && child.tagName === 'my-component');
expect(component).toBeDefined();
});

it('should still render GFM blockquotes', () => {
const doc = '> This is a blockquote';
const hast = mdxish(doc);

const blockquote = hast.children.find(child => child.type === 'element' && child.tagName === 'blockquote');
expect(blockquote).toBeDefined();
});
});
});
21 changes: 15 additions & 6 deletions lib/mdxish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,10 @@ import evaluateExpressions from '../processor/transform/mdxish/evaluate-expressi
import mdxishComponentBlocks from '../processor/transform/mdxish/mdxish-component-blocks';
import mdxishHtmlBlocks from '../processor/transform/mdxish/mdxish-html-blocks';
import magicBlockRestorer from '../processor/transform/mdxish/mdxish-magic-blocks';
import { processSnakeCaseComponent } from '../processor/transform/mdxish/mdxish-snake-case-components';
import mdxishTables from '../processor/transform/mdxish/mdxish-tables';
import { preprocessJSXExpressions, type JSXContext } from '../processor/transform/mdxish/preprocess-jsx-expressions';
import restoreSnakeCaseComponentNames from '../processor/transform/mdxish/restore-snake-case-component-name.ts';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This import shouldn't have a .ts extension -- assuming this is coming from the double file extension I called out in my other comment.

import variablesTextTransformer from '../processor/transform/mdxish/variables-text';
import tailwindTransformer from '../processor/transform/tailwind';

Expand Down Expand Up @@ -53,11 +55,17 @@ export function mdxish(mdContent: string, opts: MdxishOpts = {}): Root {
...userComponents,
};

// Preprocess content: extract legacy magic blocks and evaluate JSX attribute expressions
const { replaced, blocks } = extractMagicBlocks(mdContent);
const processedContent = preprocessJSXExpressions(replaced, jsxContext);
// Preprocessing pipeline: Transform content to be parser-ready
// Step 1: Extract legacy magic blocks
const { replaced: contentAfterMagicBlocks, blocks } = extractMagicBlocks(mdContent);
// Step 2: Evaluate JSX expressions in attributes
const contentAfterJSXEvaluation = preprocessJSXExpressions(contentAfterMagicBlocks, jsxContext);
// Step 3: Replace snake_case component names with parser-safe placeholders
// (e.g., <Snake_case /> → <MDXishSnakeCase0 /> which will be restored after parsing)
const { content: parserReadyContent, mapping: snakeCaseMapping } =
processSnakeCaseComponent(contentAfterJSXEvaluation);

// Create string map of components for tailwind transformer
// Create string map for tailwind transformer
const tempComponentsMap = Object.entries(components).reduce((acc, [key, value]) => {
acc[key] = String(value);
return acc;
Expand All @@ -72,6 +80,7 @@ export function mdxish(mdContent: string, opts: MdxishOpts = {}): Root {
.use(imageTransformer, { isMdxish: true })
.use(defaultTransformers)
.use(mdxishComponentBlocks)
.use(restoreSnakeCaseComponentNames, { mapping: snakeCaseMapping })
.use(mdxishTables)
.use(mdxishHtmlBlocks)
.use(evaluateExpressions, { context: jsxContext }) // Evaluate MDX expressions using jsxContext
Expand All @@ -86,8 +95,8 @@ export function mdxish(mdContent: string, opts: MdxishOpts = {}): Root {
processMarkdown: (markdown: string) => mdxish(markdown, opts),
});

const vfile = new VFile({ value: processedContent });
const hast = processor.runSync(processor.parse(processedContent), vfile) as Root;
const vfile = new VFile({ value: parserReadyContent });
const hast = processor.runSync(processor.parse(parserReadyContent), vfile) as Root;

if (!hast) {
throw new Error('Markdown pipeline did not produce a HAST tree.');
Expand Down
6 changes: 3 additions & 3 deletions lib/mdxishTags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,13 @@ import { extractMagicBlocks } from './utils/extractMagicBlocks';

const tags = (doc: string) => {
const { replaced: sanitizedDoc } = extractMagicBlocks(doc);

const set = new Set<string>();
const processor = remark()
.use(mdxishComponentBlocks);
const processor = remark().use(mdxishComponentBlocks);
const tree = processor.parse(sanitizedDoc);

visit(processor.runSync(tree), isMDXElement, (node: MdxJsxFlowElement | MdxJsxTextElement) => {
if (node.name?.match(/^[A-Z]/)) {
if (node.name?.match(/^[A-Z][A-Za-z_]*$/)) {
Copy link
Contributor

@kevinports kevinports Jan 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This regex excludes digits, but it should include them right? Similar to the tagPattern regex in mdxish-component-blocks.ts which does support digits: https://github.com/readmeio/markdown/pull/1281/changes#diff-7a46d21986108702ce1c95b481fa056a1e0774ff9fdac6dc983a054c265a0b32R8

set.add(node.name);
}
});
Expand Down
2 changes: 1 addition & 1 deletion processor/transform/mdxish/mdxish-component-blocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { Plugin } from 'unified';
import remarkParse from 'remark-parse';
import { unified } from 'unified';

const tagPattern = /^<([A-Z][A-Za-z0-9]*)([^>]*?)(\/?)>([\s\S]*)?$/;
const tagPattern = /^<([A-Z][A-Za-z0-9_]*)([^>]*?)(\/?)>([\s\S]*)?$/;
const attributePattern = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)(?:\s*=\s*("[^"]*"|'[^']*'|[^\s"'>]+))?/g;

const inlineMdProcessor = unified().use(remarkParse);
Expand Down
Loading