-
Notifications
You must be signed in to change notification settings - Fork 16
fix(mdxish): can't render RC with snake_case name #1281
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: next
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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(); | ||
| }); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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'; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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'; | ||
|
|
||
|
|
@@ -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; | ||
|
|
@@ -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 | ||
|
|
@@ -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.'); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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_]*$/)) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| set.add(node.name); | ||
| } | ||
| }); | ||
|
|
||
There was a problem hiding this comment.
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__/libmap directly to a function exported fromlib.