From 00358e35fc0c13d8784da8a9100f8e2cb40cf441 Mon Sep 17 00:00:00 2001 From: Jadenzzz <94533693+Jadenzzz@users.noreply.github.com> Date: Thu, 25 Dec 2025 03:50:54 +1100 Subject: [PATCH 1/7] first commit --- __tests__/lib/mdxish-snake-case.test.ts | 311 ++++++++++++++++++ lib/mdxish.ts | 27 +- lib/mdxishTags.ts | 24 +- lib/utils/mdxish/preprocessComponentNames.ts | 102 ++++++ .../mdxish/mdxish-component-blocks.ts | 2 +- .../mdxish/restore-component-names.ts | 77 +++++ 6 files changed, 522 insertions(+), 21 deletions(-) create mode 100644 __tests__/lib/mdxish-snake-case.test.ts create mode 100644 lib/utils/mdxish/preprocessComponentNames.ts create mode 100644 processor/transform/mdxish/restore-component-names.ts diff --git a/__tests__/lib/mdxish-snake-case.test.ts b/__tests__/lib/mdxish-snake-case.test.ts new file mode 100644 index 000000000..6c8913f45 --- /dev/null +++ b/__tests__/lib/mdxish-snake-case.test.ts @@ -0,0 +1,311 @@ +import { mdxishTags } from '../../lib'; +import { mdxish } from '../../lib/mdxish'; +import { type RMDXModule } from '../../types'; + +const stubModule: RMDXModule = { + default: () => null, + Toc: null, + toc: [], +}; + +const makeComponents = (...names: string[]) => + names.reduce>((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 = ''; + 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 = ''; + 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 = ''; + 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 = ` +Simple text content +`; + + 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'); + if (component?.type === 'element') { + expect(component.children.length).toBeGreaterThan(0); + } + }); + + it('should render snake_case component with markdown content', () => { + const doc = ` + +# Heading + +Some **bold** and *italic* text. + +`; + + 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(); + if (component?.type === 'element') { + expect(component.children.length).toBeGreaterThan(0); + } + }); + }); + + describe('components with attributes', () => { + it('should preserve string attributes', () => { + const doc = ''; + 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(); + if (component?.type === 'element') { + expect(component.properties?.theme).toBe('info'); + expect(component.properties?.id).toBe('test-id'); + } + }); + + it('should preserve boolean attributes', () => { + const doc = ''; + 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(); + if (component?.type === 'element') { + expect(component.properties?.empty).toBeDefined(); + } + }); + }); + + describe('multiple components', () => { + it('should render multiple instances of same snake_case component', () => { + const doc = ` + + + +`; + + 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.length).toBe(3); + }); + + it('should render multiple different snake_case components', () => { + const doc = ` + + + +`; + + 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.length).toBe(2); + expect(secondComponents.length).toBe(1); + }); + }); + + describe('nested components', () => { + it('should handle nested snake_case components', () => { + const doc = ` + + + +`; + + 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(); + + if (outerComponent?.type === 'element') { + const innerComponent = outerComponent.children.find( + child => child.type === 'element' && (child as any).tagName === 'Inner_Component', + ); + expect(innerComponent).toBeDefined(); + } + }); + }); + + describe('mixed component types', () => { + it('should handle snake_case alongside PascalCase components', () => { + const doc = ` + +`; + + 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. + + + +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 = ''; + 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 = '\n\n'; + 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 = ''; + 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 = ''; + 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(); + }); + }); +}); + +describe('mdxishTags with snake_case', () => { + it('should extract snake_case component names', () => { + const doc = ''; + const tags = mdxishTags(doc); + + expect(tags).toContain('Snake_case'); + }); + + it('should extract multiple different snake_case components', () => { + const doc = '\n'; + const tags = mdxishTags(doc); + + expect(tags).toContain('First_Component'); + expect(tags).toContain('Second_Component'); + }); + + it('should extract snake_case alongside PascalCase', () => { + const doc = '\n'; + const tags = mdxishTags(doc); + + expect(tags).toContain('Snake_case'); + expect(tags).toContain('PascalCase'); + }); + + it('should not duplicate tags for multiple occurrences', () => { + const doc = '\n\n'; + const tags = mdxishTags(doc); + + const snakeCaseCount = tags.filter(tag => tag === 'Snake_case').length; + expect(snakeCaseCount).toBe(1); + }); +}); diff --git a/lib/mdxish.ts b/lib/mdxish.ts index c20b5caf1..5dc79074c 100644 --- a/lib/mdxish.ts +++ b/lib/mdxish.ts @@ -25,11 +25,13 @@ import mdxishHtmlBlocks from '../processor/transform/mdxish/mdxish-html-blocks'; import magicBlockRestorer from '../processor/transform/mdxish/mdxish-magic-blocks'; import mdxishTables from '../processor/transform/mdxish/mdxish-tables'; import { preprocessJSXExpressions, type JSXContext } from '../processor/transform/mdxish/preprocess-jsx-expressions'; +import restoreComponentNames from '../processor/transform/mdxish/restore-component-names'; import variablesTextTransformer from '../processor/transform/mdxish/variables-text'; import tailwindTransformer from '../processor/transform/tailwind'; import { extractMagicBlocks } from './utils/extractMagicBlocks'; import { loadComponents } from './utils/mdxish/mdxish-load-components'; +import { preprocessComponentNames } from './utils/mdxish/preprocessComponentNames'; export interface MdxishOpts { components?: CustomComponents; @@ -57,8 +59,24 @@ export function mdxish(mdContent: string, opts: MdxishOpts = {}): Root { const { replaced, blocks } = extractMagicBlocks(mdContent); const processedContent = preprocessJSXExpressions(replaced, jsxContext); - // Create string map of components for tailwind transformer - const tempComponentsMap = Object.entries(components).reduce((acc, [key, value]) => { + const { content: preprocessedContent, mapping } = preprocessComponentNames(processedContent); + + // Remap components hash to use placeholder names as keys + // This way components can be found by their placeholder names during processing + const remappedComponents: CustomComponents = {}; + Object.entries(components).forEach(([originalName, component]) => { + // Find the placeholder for this component name (if it was remapped) + const placeholder = Object.keys(mapping).find(key => mapping[key] === originalName); + if (placeholder) { + // Use placeholder as key + remappedComponents[placeholder] = component; + } else { + // No remapping needed, use original name + remappedComponents[originalName] = component; + } + }); + + const tempComponentsMap = Object.entries(remappedComponents).reduce((acc, [key, value]) => { acc[key] = String(value); return acc; }, {}); @@ -72,6 +90,7 @@ export function mdxish(mdContent: string, opts: MdxishOpts = {}): Root { .use(imageTransformer, { isMdxish: true }) .use(defaultTransformers) .use(mdxishComponentBlocks) + .use(restoreComponentNames, { mapping }) // Restores names so HAST matches components .use(mdxishTables) .use(mdxishHtmlBlocks) .use(evaluateExpressions, { context: jsxContext }) // Evaluate MDX expressions using jsxContext @@ -86,8 +105,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: preprocessedContent }); + const hast = processor.runSync(processor.parse(preprocessedContent), vfile) as Root; if (!hast) { throw new Error('Markdown pipeline did not produce a HAST tree.'); diff --git a/lib/mdxishTags.ts b/lib/mdxishTags.ts index dc3214240..ca4049262 100644 --- a/lib/mdxishTags.ts +++ b/lib/mdxishTags.ts @@ -1,25 +1,17 @@ -import type { MdxJsxFlowElement, MdxJsxTextElement } from 'mdast-util-mdx'; - -import { remark } from 'remark'; -import { visit } from 'unist-util-visit'; - -import mdxishComponentBlocks from '../processor/transform/mdxish/mdxish-component-blocks'; -import { isMDXElement } from '../processor/utils'; - import { extractMagicBlocks } from './utils/extractMagicBlocks'; const tags = (doc: string) => { const { replaced: sanitizedDoc } = extractMagicBlocks(doc); + const set = new Set(); - const processor = remark() - .use(mdxishComponentBlocks); - const tree = processor.parse(sanitizedDoc); - visit(processor.runSync(tree), isMDXElement, (node: MdxJsxFlowElement | MdxJsxTextElement) => { - if (node.name?.match(/^[A-Z]/)) { - set.add(node.name); - } - }); + + const componentPattern = /<\/?([A-Z][A-Za-z0-9_]*)(?:\s[^>]*)?\/?>/g; + let match: RegExpExecArray | null; + while ((match = componentPattern.exec(sanitizedDoc)) !== null) { + const tagName = match[1]; + set.add(tagName); + } return Array.from(set); }; diff --git a/lib/utils/mdxish/preprocessComponentNames.ts b/lib/utils/mdxish/preprocessComponentNames.ts new file mode 100644 index 000000000..c928485bb --- /dev/null +++ b/lib/utils/mdxish/preprocessComponentNames.ts @@ -0,0 +1,102 @@ +/** + * Mapping between placeholder component names and original names with underscores + */ +export type ComponentNameMapping = Record; + +/** + * Result of preprocessing component names + */ +export interface PreprocessResult { + content: string; + mapping: ComponentNameMapping; +} + +/** + * Preprocesses component names with underscores by replacing them with placeholders. + * + * WHY THIS IS NECESSARY: + * The remark-parse library (the markdown parser) has strict rules about what constitutes + * a valid HTML tag name. According to HTML spec, tag names cannot contain underscores. + * + * THE SOLUTION: + * 1. BEFORE parsing: Replace `` with `` + * 2. Parser sees valid HTML: Creates an HTML node that can be processed + * 3. AFTER parsing: Restore the original name `Snake_case` in the AST + * + * @param content - The raw markdown content + * @returns Object containing processed content and mapping to restore names + */ +export function preprocessComponentNames(content: string): PreprocessResult { + const mapping: ComponentNameMapping = {}; + let counter = 0; + + // Match all component-style tags that start with uppercase letter + // Captures: opening/closing slash, tag name, attributes, self-closing slash + const componentTagPattern = /<(\/?[A-Z][A-Za-z0-9_]*)([^>]*?)(\/?)>/g; + + const processedContent = content.replace(componentTagPattern, (match, tagName, attrs, selfClosing) => { + // Only process tags that contain underscores + if (!tagName.includes('_')) { + return match; + } + + // Handle closing tags (e.g., ) + const isClosing = tagName.startsWith('/'); + const cleanTagName = isClosing ? tagName.slice(1) : tagName; + + // Reuse existing placeholder if we've seen this component name before + let placeholder = Object.keys(mapping).find(key => mapping[key] === cleanTagName); + + if (!placeholder) { + // Generate unique placeholder that is guaranteed to be valid HTML + // Format: RdmxSnakeCase0, RdmxSnakeCase1, etc. + // eslint-disable-next-line no-plusplus + placeholder = `RdmxSnakeCase${counter++}`; + mapping[placeholder] = cleanTagName; + } + + const processedTagName = isClosing ? `/${placeholder}` : placeholder; + const result = `<${processedTagName}${attrs}${selfClosing}>`; + + return result; + }); + + return { + content: processedContent, + mapping, + }; +} + +/** + * Restores original component name with underscores from placeholder + * + * IMPORTANT: This function performs case-insensitive lookups because HTML parsers + * normalize tag names to lowercase. So even though we generate placeholders like + * "RdmxSnakeCase0", the AST might contain "rdmxsnakecase0". + * + * @param placeholderName - The placeholder name (e.g., "RdmxSnakeCase0" or "rdmxsnakecase0") + * @param mapping - The mapping from preprocessing + * @returns The original component name (e.g., "Snake_case") or input if not found + */ +export function restoreComponentName(placeholderName: string, mapping: ComponentNameMapping): string { + // Try exact match first (fastest path) + if (mapping[placeholderName]) { + return mapping[placeholderName]; + } + + // Try case-insensitive match + // HTML parsers normalize tag names to lowercase, so "RdmxSnakeCase0" becomes "rdmxsnakecase0" + const lowerName = placeholderName.toLowerCase(); + const matchingKey = Object.keys(mapping).find(key => key.toLowerCase() === lowerName); + + if (matchingKey) { + // eslint-disable-next-line no-console + console.log( + `[restoreComponentName] Case-insensitive match: "${placeholderName}" matched "${matchingKey}" → "${mapping[matchingKey]}"`, + ); + return mapping[matchingKey]; + } + + // No match found, return original + return placeholderName; +} diff --git a/processor/transform/mdxish/mdxish-component-blocks.ts b/processor/transform/mdxish/mdxish-component-blocks.ts index 53e6e2299..14784fa68 100644 --- a/processor/transform/mdxish/mdxish-component-blocks.ts +++ b/processor/transform/mdxish/mdxish-component-blocks.ts @@ -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); diff --git a/processor/transform/mdxish/restore-component-names.ts b/processor/transform/mdxish/restore-component-names.ts new file mode 100644 index 000000000..2ee9300ec --- /dev/null +++ b/processor/transform/mdxish/restore-component-names.ts @@ -0,0 +1,77 @@ +import type { ComponentNameMapping } from '../../../lib/utils/mdxish/preprocessComponentNames'; +import type { Parent } from 'mdast'; +import type { Html } from 'mdast'; +import type { MdxJsxFlowElement } from 'mdast-util-mdx-jsx'; +import type { Plugin } from 'unified'; + +import { visit } from 'unist-util-visit'; + +import { restoreComponentName } from '../../../lib/utils/mdxish/preprocessComponentNames'; + +interface Options { + mapping: ComponentNameMapping; +} + +/** + * Unified/remark plugin that restores original component names with underscores. + * + * CONTEXT: + * This plugin runs AFTER mdxishComponentBlocks has converted HTML nodes into + * mdxJsxFlowElement nodes. At this point, the AST contains nodes with placeholder + * names like `RdmxSnakeCase0` instead of the original `Snake_case`. + * + * WHAT IT DOES: + * 1. Visits all mdxJsxFlowElement nodes and restores their original names + * 2. Also checks raw HTML nodes (in case some weren't converted) and restores names there + * + * EXAMPLE: + * Input AST: { type: 'mdxJsxFlowElement', name: 'RdmxSnakeCase0', ... } + * Output AST: { type: 'mdxJsxFlowElement', name: 'Snake_case', ... } + * + * This ensures that when rehypeMdxishComponents runs later, it sees the correct + * component name and can match it against the components hash. + */ +const restoreComponentNames: Plugin<[Options], Parent> = (options: Options) => { + const { mapping } = options; + + return tree => { + // Skip if no snake_case components were found during preprocessing + if (!mapping || Object.keys(mapping).length === 0) { + return tree; + } + + // Restore names in mdxJsxFlowElement nodes + visit(tree, 'mdxJsxFlowElement', (node: MdxJsxFlowElement) => { + if (node.name) { + node.name = restoreComponentName(node.name, mapping); + } + }); + + // Restore names in raw HTML nodes + visit(tree, 'html', (node: Html) => { + if (node.value) { + // Restore placeholders in raw HTML strings + // This handles cases where mdxishComponentBlocks didn't convert the node yet + // (e.g., inline components or complex nested structures) + let newValue = node.value; + Object.entries(mapping).forEach(([placeholder, original]) => { + // Match the placeholder in opening tags, closing tags, and self-closing tags + // Pattern: , + // Use case-insensitive flag 'i' because HTML parsers may normalize to lowercase + const regex = new RegExp(`(<\\/?)(${placeholder})(\\s|\\/?>)`, 'gi'); + newValue = newValue.replace(regex, `$1${original}$3`); + }); + + if (newValue !== node.value) { + // Debug logging disabled for production + // console.log(`[restoreComponentNames] Restored HTML node: "${node.value}" → "${newValue}"`); + node.value = newValue; + } + } + }); + + return tree; + }; +}; + +export default restoreComponentNames; From e0b722b1090345320233bb9d49664e0386019eca Mon Sep 17 00:00:00 2001 From: Jadenzzz <94533693+Jadenzzz@users.noreply.github.com> Date: Thu, 25 Dec 2025 15:16:10 +1100 Subject: [PATCH 2/7] fix: clean code --- __tests__/lib/mdxish-snake-case.test.ts | 48 +++++---- lib/mdxish.ts | 27 ++--- lib/utils/mdxish/preprocessComponentNames.ts | 102 ------------------ .../mdxish/mdxish-snake-case-components.ts | 65 +++++++++++ .../mdxish/restore-component-names.ts | 77 ------------- .../restore-snake-case-component-name.ts.ts | 56 ++++++++++ 6 files changed, 154 insertions(+), 221 deletions(-) delete mode 100644 lib/utils/mdxish/preprocessComponentNames.ts create mode 100644 processor/transform/mdxish/mdxish-snake-case-components.ts delete mode 100644 processor/transform/mdxish/restore-component-names.ts create mode 100644 processor/transform/mdxish/restore-snake-case-component-name.ts.ts diff --git a/__tests__/lib/mdxish-snake-case.test.ts b/__tests__/lib/mdxish-snake-case.test.ts index 6c8913f45..ecffd9392 100644 --- a/__tests__/lib/mdxish-snake-case.test.ts +++ b/__tests__/lib/mdxish-snake-case.test.ts @@ -1,3 +1,5 @@ +import type { Element } from 'hast'; + import { mdxishTags } from '../../lib'; import { mdxish } from '../../lib/mdxish'; import { type RMDXModule } from '../../types'; @@ -64,9 +66,9 @@ Simple text content const component = hast.children.find(child => child.type === 'element' && child.tagName === 'Snake_case'); expect(component).toBeDefined(); expect(component?.type).toBe('element'); - if (component?.type === 'element') { - expect(component.children.length).toBeGreaterThan(0); - } + + const elementNode = component as Element; + expect(elementNode.children.length).toBeGreaterThan(0); }); it('should render snake_case component with markdown content', () => { @@ -84,9 +86,9 @@ Some **bold** and *italic* text. const component = hast.children.find(child => child.type === 'element' && child.tagName === 'Snake_case'); expect(component).toBeDefined(); - if (component?.type === 'element') { - expect(component.children.length).toBeGreaterThan(0); - } + + const elementNode = component as Element; + expect(elementNode.children.length).toBeGreaterThan(0); }); }); @@ -99,10 +101,11 @@ Some **bold** and *italic* text. const component = hast.children.find(child => child.type === 'element' && child.tagName === 'Snake_case'); expect(component).toBeDefined(); - if (component?.type === 'element') { - expect(component.properties?.theme).toBe('info'); - expect(component.properties?.id).toBe('test-id'); - } + 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', () => { @@ -113,9 +116,10 @@ Some **bold** and *italic* text. const component = hast.children.find(child => child.type === 'element' && child.tagName === 'Snake_case'); expect(component).toBeDefined(); - if (component?.type === 'element') { - expect(component.properties?.empty).toBeDefined(); - } + expect(component?.type).toBe('element'); + + const elementNode = component as Element; + expect(elementNode.properties?.empty).toBeDefined(); }); }); @@ -132,7 +136,7 @@ Some **bold** and *italic* text. const hast = mdxish(doc, { components }); const componentsFound = hast.children.filter(child => child.type === 'element' && child.tagName === 'Snake_case'); - expect(componentsFound.length).toBe(3); + expect(componentsFound).toHaveLength(3); }); it('should render multiple different snake_case components', () => { @@ -153,8 +157,8 @@ Some **bold** and *italic* text. child => child.type === 'element' && child.tagName === 'Second_Component', ); - expect(firstComponents.length).toBe(2); - expect(secondComponents.length).toBe(1); + expect(firstComponents).toHaveLength(2); + expect(secondComponents).toHaveLength(1); }); }); @@ -174,13 +178,13 @@ Some **bold** and *italic* text. child => child.type === 'element' && child.tagName === 'Outer_Component', ); expect(outerComponent).toBeDefined(); + expect(outerComponent?.type).toBe('element'); - if (outerComponent?.type === 'element') { - const innerComponent = outerComponent.children.find( - child => child.type === 'element' && (child as any).tagName === 'Inner_Component', - ); - expect(innerComponent).toBeDefined(); - } + const outerElement = outerComponent as Element; + const innerComponent = outerElement.children.find( + child => child.type === 'element' && (child as Element).tagName === 'Inner_Component', + ); + expect(innerComponent).toBeDefined(); }); }); diff --git a/lib/mdxish.ts b/lib/mdxish.ts index 5dc79074c..2c3c42939 100644 --- a/lib/mdxish.ts +++ b/lib/mdxish.ts @@ -23,15 +23,15 @@ 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 restoreComponentNames from '../processor/transform/mdxish/restore-component-names'; +import restoreSnakeCaseComponentNames from '../processor/transform/mdxish/restore-snake-case-component-name.ts'; import variablesTextTransformer from '../processor/transform/mdxish/variables-text'; import tailwindTransformer from '../processor/transform/tailwind'; import { extractMagicBlocks } from './utils/extractMagicBlocks'; import { loadComponents } from './utils/mdxish/mdxish-load-components'; -import { preprocessComponentNames } from './utils/mdxish/preprocessComponentNames'; export interface MdxishOpts { components?: CustomComponents; @@ -59,24 +59,11 @@ export function mdxish(mdContent: string, opts: MdxishOpts = {}): Root { const { replaced, blocks } = extractMagicBlocks(mdContent); const processedContent = preprocessJSXExpressions(replaced, jsxContext); - const { content: preprocessedContent, mapping } = preprocessComponentNames(processedContent); + // Preprocess snake_case names for parsing + const { content: preprocessedContent, mapping } = processSnakeCaseComponent(processedContent); - // Remap components hash to use placeholder names as keys - // This way components can be found by their placeholder names during processing - const remappedComponents: CustomComponents = {}; - Object.entries(components).forEach(([originalName, component]) => { - // Find the placeholder for this component name (if it was remapped) - const placeholder = Object.keys(mapping).find(key => mapping[key] === originalName); - if (placeholder) { - // Use placeholder as key - remappedComponents[placeholder] = component; - } else { - // No remapping needed, use original name - remappedComponents[originalName] = component; - } - }); - - const tempComponentsMap = Object.entries(remappedComponents).reduce((acc, [key, value]) => { + // Create string map for tailwind transformer + const tempComponentsMap = Object.entries(components).reduce((acc, [key, value]) => { acc[key] = String(value); return acc; }, {}); @@ -90,7 +77,7 @@ export function mdxish(mdContent: string, opts: MdxishOpts = {}): Root { .use(imageTransformer, { isMdxish: true }) .use(defaultTransformers) .use(mdxishComponentBlocks) - .use(restoreComponentNames, { mapping }) // Restores names so HAST matches components + .use(restoreSnakeCaseComponentNames, { mapping }) .use(mdxishTables) .use(mdxishHtmlBlocks) .use(evaluateExpressions, { context: jsxContext }) // Evaluate MDX expressions using jsxContext diff --git a/lib/utils/mdxish/preprocessComponentNames.ts b/lib/utils/mdxish/preprocessComponentNames.ts deleted file mode 100644 index c928485bb..000000000 --- a/lib/utils/mdxish/preprocessComponentNames.ts +++ /dev/null @@ -1,102 +0,0 @@ -/** - * Mapping between placeholder component names and original names with underscores - */ -export type ComponentNameMapping = Record; - -/** - * Result of preprocessing component names - */ -export interface PreprocessResult { - content: string; - mapping: ComponentNameMapping; -} - -/** - * Preprocesses component names with underscores by replacing them with placeholders. - * - * WHY THIS IS NECESSARY: - * The remark-parse library (the markdown parser) has strict rules about what constitutes - * a valid HTML tag name. According to HTML spec, tag names cannot contain underscores. - * - * THE SOLUTION: - * 1. BEFORE parsing: Replace `` with `` - * 2. Parser sees valid HTML: Creates an HTML node that can be processed - * 3. AFTER parsing: Restore the original name `Snake_case` in the AST - * - * @param content - The raw markdown content - * @returns Object containing processed content and mapping to restore names - */ -export function preprocessComponentNames(content: string): PreprocessResult { - const mapping: ComponentNameMapping = {}; - let counter = 0; - - // Match all component-style tags that start with uppercase letter - // Captures: opening/closing slash, tag name, attributes, self-closing slash - const componentTagPattern = /<(\/?[A-Z][A-Za-z0-9_]*)([^>]*?)(\/?)>/g; - - const processedContent = content.replace(componentTagPattern, (match, tagName, attrs, selfClosing) => { - // Only process tags that contain underscores - if (!tagName.includes('_')) { - return match; - } - - // Handle closing tags (e.g., ) - const isClosing = tagName.startsWith('/'); - const cleanTagName = isClosing ? tagName.slice(1) : tagName; - - // Reuse existing placeholder if we've seen this component name before - let placeholder = Object.keys(mapping).find(key => mapping[key] === cleanTagName); - - if (!placeholder) { - // Generate unique placeholder that is guaranteed to be valid HTML - // Format: RdmxSnakeCase0, RdmxSnakeCase1, etc. - // eslint-disable-next-line no-plusplus - placeholder = `RdmxSnakeCase${counter++}`; - mapping[placeholder] = cleanTagName; - } - - const processedTagName = isClosing ? `/${placeholder}` : placeholder; - const result = `<${processedTagName}${attrs}${selfClosing}>`; - - return result; - }); - - return { - content: processedContent, - mapping, - }; -} - -/** - * Restores original component name with underscores from placeholder - * - * IMPORTANT: This function performs case-insensitive lookups because HTML parsers - * normalize tag names to lowercase. So even though we generate placeholders like - * "RdmxSnakeCase0", the AST might contain "rdmxsnakecase0". - * - * @param placeholderName - The placeholder name (e.g., "RdmxSnakeCase0" or "rdmxsnakecase0") - * @param mapping - The mapping from preprocessing - * @returns The original component name (e.g., "Snake_case") or input if not found - */ -export function restoreComponentName(placeholderName: string, mapping: ComponentNameMapping): string { - // Try exact match first (fastest path) - if (mapping[placeholderName]) { - return mapping[placeholderName]; - } - - // Try case-insensitive match - // HTML parsers normalize tag names to lowercase, so "RdmxSnakeCase0" becomes "rdmxsnakecase0" - const lowerName = placeholderName.toLowerCase(); - const matchingKey = Object.keys(mapping).find(key => key.toLowerCase() === lowerName); - - if (matchingKey) { - // eslint-disable-next-line no-console - console.log( - `[restoreComponentName] Case-insensitive match: "${placeholderName}" matched "${matchingKey}" → "${mapping[matchingKey]}"`, - ); - return mapping[matchingKey]; - } - - // No match found, return original - return placeholderName; -} diff --git a/processor/transform/mdxish/mdxish-snake-case-components.ts b/processor/transform/mdxish/mdxish-snake-case-components.ts new file mode 100644 index 000000000..f5a5075a2 --- /dev/null +++ b/processor/transform/mdxish/mdxish-snake-case-components.ts @@ -0,0 +1,65 @@ +export type SnakeCaseMapping = Record; + +export interface SnakeCasePreprocessResult { + content: string; + mapping: SnakeCaseMapping; +} + +/** + * Replaces snake_case component names with valid HTML placeholders. + * Required because remark-parse rejects tags with underscores. + * Example: `` → `` + */ +export function processSnakeCaseComponent(content: string): SnakeCasePreprocessResult { + // Early exit if no potential snake_case components + if (!/[A-Z][A-Za-z0-9]*_[A-Za-z0-9_]*/.test(content)) { + return { content, mapping: {} }; + } + + const mapping: SnakeCaseMapping = {}; + const reverseMap = new Map(); + let counter = 0; + + const componentTagPattern = /<(\/?[A-Z][A-Za-z0-9_]*)([^>]*?)(\/?)>/g; + + const processedContent = content.replace(componentTagPattern, (match, tagName, attrs, selfClosing) => { + if (!tagName.includes('_')) { + return match; + } + + const isClosing = tagName.startsWith('/'); + const cleanTagName = isClosing ? tagName.slice(1) : tagName; + + let placeholder = reverseMap.get(cleanTagName); + + if (!placeholder) { + // eslint-disable-next-line no-plusplus + placeholder = `RdmxSnakeCase${counter++}`; + mapping[placeholder] = cleanTagName; + reverseMap.set(cleanTagName, placeholder); + } + + const processedTagName = isClosing ? `/${placeholder}` : placeholder; + return `<${processedTagName}${attrs}${selfClosing}>`; + }); + + return { + content: processedContent, + mapping, + }; +} + +/** + * Restores placeholder name to original snake_case name. + * Uses case-insensitive matching since HTML parsers normalize to lowercase. + */ +export function restoreSnakeCase(placeholderName: string, mapping: SnakeCaseMapping): string { + if (mapping[placeholderName]) { + return mapping[placeholderName]; + } + + const lowerName = placeholderName.toLowerCase(); + const matchingKey = Object.keys(mapping).find(key => key.toLowerCase() === lowerName); + + return matchingKey ? mapping[matchingKey] : placeholderName; +} diff --git a/processor/transform/mdxish/restore-component-names.ts b/processor/transform/mdxish/restore-component-names.ts deleted file mode 100644 index 2ee9300ec..000000000 --- a/processor/transform/mdxish/restore-component-names.ts +++ /dev/null @@ -1,77 +0,0 @@ -import type { ComponentNameMapping } from '../../../lib/utils/mdxish/preprocessComponentNames'; -import type { Parent } from 'mdast'; -import type { Html } from 'mdast'; -import type { MdxJsxFlowElement } from 'mdast-util-mdx-jsx'; -import type { Plugin } from 'unified'; - -import { visit } from 'unist-util-visit'; - -import { restoreComponentName } from '../../../lib/utils/mdxish/preprocessComponentNames'; - -interface Options { - mapping: ComponentNameMapping; -} - -/** - * Unified/remark plugin that restores original component names with underscores. - * - * CONTEXT: - * This plugin runs AFTER mdxishComponentBlocks has converted HTML nodes into - * mdxJsxFlowElement nodes. At this point, the AST contains nodes with placeholder - * names like `RdmxSnakeCase0` instead of the original `Snake_case`. - * - * WHAT IT DOES: - * 1. Visits all mdxJsxFlowElement nodes and restores their original names - * 2. Also checks raw HTML nodes (in case some weren't converted) and restores names there - * - * EXAMPLE: - * Input AST: { type: 'mdxJsxFlowElement', name: 'RdmxSnakeCase0', ... } - * Output AST: { type: 'mdxJsxFlowElement', name: 'Snake_case', ... } - * - * This ensures that when rehypeMdxishComponents runs later, it sees the correct - * component name and can match it against the components hash. - */ -const restoreComponentNames: Plugin<[Options], Parent> = (options: Options) => { - const { mapping } = options; - - return tree => { - // Skip if no snake_case components were found during preprocessing - if (!mapping || Object.keys(mapping).length === 0) { - return tree; - } - - // Restore names in mdxJsxFlowElement nodes - visit(tree, 'mdxJsxFlowElement', (node: MdxJsxFlowElement) => { - if (node.name) { - node.name = restoreComponentName(node.name, mapping); - } - }); - - // Restore names in raw HTML nodes - visit(tree, 'html', (node: Html) => { - if (node.value) { - // Restore placeholders in raw HTML strings - // This handles cases where mdxishComponentBlocks didn't convert the node yet - // (e.g., inline components or complex nested structures) - let newValue = node.value; - Object.entries(mapping).forEach(([placeholder, original]) => { - // Match the placeholder in opening tags, closing tags, and self-closing tags - // Pattern: , - // Use case-insensitive flag 'i' because HTML parsers may normalize to lowercase - const regex = new RegExp(`(<\\/?)(${placeholder})(\\s|\\/?>)`, 'gi'); - newValue = newValue.replace(regex, `$1${original}$3`); - }); - - if (newValue !== node.value) { - // Debug logging disabled for production - // console.log(`[restoreComponentNames] Restored HTML node: "${node.value}" → "${newValue}"`); - node.value = newValue; - } - } - }); - - return tree; - }; -}; - -export default restoreComponentNames; diff --git a/processor/transform/mdxish/restore-snake-case-component-name.ts.ts b/processor/transform/mdxish/restore-snake-case-component-name.ts.ts new file mode 100644 index 000000000..8b9c42029 --- /dev/null +++ b/processor/transform/mdxish/restore-snake-case-component-name.ts.ts @@ -0,0 +1,56 @@ +import type { SnakeCaseMapping } from './mdxish-snake-case-components'; +import type { Parent, Html } from 'mdast'; +import type { MdxJsxFlowElement } from 'mdast-util-mdx-jsx'; +import type { Plugin } from 'unified'; + +import { visit } from 'unist-util-visit'; + +import { restoreSnakeCase } from './mdxish-snake-case-components'; + +interface Options { + mapping: SnakeCaseMapping; +} + +/** + * Restores snake_case component names from placeholders after parsing. + * Runs after mdxishComponentBlocks converts HTML nodes to mdxJsxFlowElement. + */ +const restoreSnakeCaseComponentNames: Plugin<[Options], Parent> = (options: Options) => { + const { mapping } = options; + + return tree => { + if (!mapping || Object.keys(mapping).length === 0) { + return tree; + } + + visit(tree, 'mdxJsxFlowElement', (node: MdxJsxFlowElement) => { + if (node.name) { + node.name = restoreSnakeCase(node.name, mapping); + } + }); + + // Pre-compile regex patterns for better performance + const regexPatterns = Object.entries(mapping).map(([placeholder, original]) => ({ + regex: new RegExp(`(<\\/?)(${placeholder})(\\s|\\/?>)`, 'gi'), + original, + })); + + visit(tree, 'html', (node: Html) => { + if (node.value) { + let newValue = node.value; + + regexPatterns.forEach(({ regex, original }) => { + newValue = newValue.replace(regex, `$1${original}$3`); + }); + + if (newValue !== node.value) { + node.value = newValue; + } + } + }); + + return tree; + }; +}; + +export default restoreSnakeCaseComponentNames; From 9a5c5519de593cbb81eb6759c19f6a5b20174cd8 Mon Sep 17 00:00:00 2001 From: Jadenzzz <94533693+Jadenzzz@users.noreply.github.com> Date: Thu, 25 Dec 2025 15:26:43 +1100 Subject: [PATCH 3/7] fix --- lib/mdxishTags.ts | 27 ++++++++++++++----- .../mdxish/mdxish-snake-case-components.ts | 4 +-- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/lib/mdxishTags.ts b/lib/mdxishTags.ts index ca4049262..8703e6e75 100644 --- a/lib/mdxishTags.ts +++ b/lib/mdxishTags.ts @@ -1,17 +1,32 @@ +import type { MdxJsxFlowElement, MdxJsxTextElement } from 'mdast-util-mdx'; + +import { remark } from 'remark'; +import { visit } from 'unist-util-visit'; + +import mdxishComponentBlocks from '../processor/transform/mdxish/mdxish-component-blocks'; +import { isMDXElement } from '../processor/utils'; + import { extractMagicBlocks } from './utils/extractMagicBlocks'; const tags = (doc: string) => { const { replaced: sanitizedDoc } = extractMagicBlocks(doc); const set = new Set(); + const processor = remark().use(mdxishComponentBlocks); + const tree = processor.parse(sanitizedDoc); + visit(processor.runSync(tree), isMDXElement, (node: MdxJsxFlowElement | MdxJsxTextElement) => { + if (node.name?.match(/^[A-Z][A-Za-z_]*$/)) { + set.add(node.name); + } + }); - const componentPattern = /<\/?([A-Z][A-Za-z0-9_]*)(?:\s[^>]*)?\/?>/g; - let match: RegExpExecArray | null; - while ((match = componentPattern.exec(sanitizedDoc)) !== null) { - const tagName = match[1]; - set.add(tagName); - } + // const componentPattern = /<\/?([A-Z][A-Za-z0-9_]*)(?:\s[^>]*)?\/?>/g; + // let match: RegExpExecArray | null; + // while ((match = componentPattern.exec(sanitizedDoc)) !== null) { + // const tagName = match[1]; + // set.add(tagName); + // } return Array.from(set); }; diff --git a/processor/transform/mdxish/mdxish-snake-case-components.ts b/processor/transform/mdxish/mdxish-snake-case-components.ts index f5a5075a2..1fbc39b49 100644 --- a/processor/transform/mdxish/mdxish-snake-case-components.ts +++ b/processor/transform/mdxish/mdxish-snake-case-components.ts @@ -8,7 +8,7 @@ export interface SnakeCasePreprocessResult { /** * Replaces snake_case component names with valid HTML placeholders. * Required because remark-parse rejects tags with underscores. - * Example: `` → `` + * Example: `` → `` */ export function processSnakeCaseComponent(content: string): SnakeCasePreprocessResult { // Early exit if no potential snake_case components @@ -34,7 +34,7 @@ export function processSnakeCaseComponent(content: string): SnakeCasePreprocessR if (!placeholder) { // eslint-disable-next-line no-plusplus - placeholder = `RdmxSnakeCase${counter++}`; + placeholder = `MDXishSnakeCase${counter++}`; mapping[placeholder] = cleanTagName; reverseMap.set(cleanTagName, placeholder); } From ba444f69ff444d9d8fbb809e30cfbf5deb0075f2 Mon Sep 17 00:00:00 2001 From: Jadenzzz <94533693+Jadenzzz@users.noreply.github.com> Date: Thu, 25 Dec 2025 15:40:15 +1100 Subject: [PATCH 4/7] fix: clean code --- __tests__/lib/mdxish-snake-case.test.ts | 42 +++---------------------- lib/mdxish.ts | 21 +++++++------ 2 files changed, 16 insertions(+), 47 deletions(-) diff --git a/__tests__/lib/mdxish-snake-case.test.ts b/__tests__/lib/mdxish-snake-case.test.ts index ecffd9392..2ca77a183 100644 --- a/__tests__/lib/mdxish-snake-case.test.ts +++ b/__tests__/lib/mdxish-snake-case.test.ts @@ -1,6 +1,5 @@ import type { Element } from 'hast'; -import { mdxishTags } from '../../lib'; import { mdxish } from '../../lib/mdxish'; import { type RMDXModule } from '../../types'; @@ -66,7 +65,7 @@ Simple text content 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); }); @@ -86,7 +85,7 @@ Some **bold** and *italic* text. 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); }); @@ -102,7 +101,7 @@ Some **bold** and *italic* text. 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'); @@ -117,7 +116,7 @@ Some **bold** and *italic* text. 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(); }); @@ -280,36 +279,3 @@ More markdown after the component.`; }); }); }); - -describe('mdxishTags with snake_case', () => { - it('should extract snake_case component names', () => { - const doc = ''; - const tags = mdxishTags(doc); - - expect(tags).toContain('Snake_case'); - }); - - it('should extract multiple different snake_case components', () => { - const doc = '\n'; - const tags = mdxishTags(doc); - - expect(tags).toContain('First_Component'); - expect(tags).toContain('Second_Component'); - }); - - it('should extract snake_case alongside PascalCase', () => { - const doc = '\n'; - const tags = mdxishTags(doc); - - expect(tags).toContain('Snake_case'); - expect(tags).toContain('PascalCase'); - }); - - it('should not duplicate tags for multiple occurrences', () => { - const doc = '\n\n'; - const tags = mdxishTags(doc); - - const snakeCaseCount = tags.filter(tag => tag === 'Snake_case').length; - expect(snakeCaseCount).toBe(1); - }); -}); diff --git a/lib/mdxish.ts b/lib/mdxish.ts index 2c3c42939..ff7330246 100644 --- a/lib/mdxish.ts +++ b/lib/mdxish.ts @@ -55,12 +55,15 @@ 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); - - // Preprocess snake_case names for parsing - const { content: preprocessedContent, mapping } = processSnakeCaseComponent(processedContent); + // 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., which will be restored after parsing) + const { content: parserReadyContent, mapping: snakeCaseMapping } = + processSnakeCaseComponent(contentAfterJSXEvaluation); // Create string map for tailwind transformer const tempComponentsMap = Object.entries(components).reduce((acc, [key, value]) => { @@ -77,7 +80,7 @@ export function mdxish(mdContent: string, opts: MdxishOpts = {}): Root { .use(imageTransformer, { isMdxish: true }) .use(defaultTransformers) .use(mdxishComponentBlocks) - .use(restoreSnakeCaseComponentNames, { mapping }) + .use(restoreSnakeCaseComponentNames, { mapping: snakeCaseMapping }) .use(mdxishTables) .use(mdxishHtmlBlocks) .use(evaluateExpressions, { context: jsxContext }) // Evaluate MDX expressions using jsxContext @@ -92,8 +95,8 @@ export function mdxish(mdContent: string, opts: MdxishOpts = {}): Root { processMarkdown: (markdown: string) => mdxish(markdown, opts), }); - const vfile = new VFile({ value: preprocessedContent }); - const hast = processor.runSync(processor.parse(preprocessedContent), 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.'); From bd8f85d3c84aea8946bcb79895e4ee20c910370e Mon Sep 17 00:00:00 2001 From: Jadenzzz <94533693+Jadenzzz@users.noreply.github.com> Date: Thu, 25 Dec 2025 15:41:02 +1100 Subject: [PATCH 5/7] oops --- lib/mdxishTags.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/lib/mdxishTags.ts b/lib/mdxishTags.ts index 8703e6e75..e2661c2e0 100644 --- a/lib/mdxishTags.ts +++ b/lib/mdxishTags.ts @@ -21,13 +21,6 @@ const tags = (doc: string) => { } }); - // const componentPattern = /<\/?([A-Z][A-Za-z0-9_]*)(?:\s[^>]*)?\/?>/g; - // let match: RegExpExecArray | null; - // while ((match = componentPattern.exec(sanitizedDoc)) !== null) { - // const tagName = match[1]; - // set.add(tagName); - // } - return Array.from(set); }; From b43f1f2ca5db5609b04fe6cb4365d1d3f3b1daa0 Mon Sep 17 00:00:00 2001 From: Jadenzzz <94533693+Jadenzzz@users.noreply.github.com> Date: Wed, 7 Jan 2026 00:13:36 +1100 Subject: [PATCH 6/7] fix: feedback --- __tests__/lib/{ => mdxish}/mdxish-snake-case.test.ts | 4 ++-- lib/constants.ts | 5 +++++ lib/mdxish.ts | 2 +- lib/mdxishTags.ts | 3 ++- processor/transform/mdxish/mdxish-snake-case-components.ts | 4 ++-- ...onent-name.ts.ts => restore-snake-case-component-name.ts} | 0 6 files changed, 12 insertions(+), 6 deletions(-) rename __tests__/lib/{ => mdxish}/mdxish-snake-case.test.ts (98%) create mode 100644 lib/constants.ts rename processor/transform/mdxish/{restore-snake-case-component-name.ts.ts => restore-snake-case-component-name.ts} (100%) diff --git a/__tests__/lib/mdxish-snake-case.test.ts b/__tests__/lib/mdxish/mdxish-snake-case.test.ts similarity index 98% rename from __tests__/lib/mdxish-snake-case.test.ts rename to __tests__/lib/mdxish/mdxish-snake-case.test.ts index 2ca77a183..3e2112966 100644 --- a/__tests__/lib/mdxish-snake-case.test.ts +++ b/__tests__/lib/mdxish/mdxish-snake-case.test.ts @@ -1,7 +1,7 @@ import type { Element } from 'hast'; -import { mdxish } from '../../lib/mdxish'; -import { type RMDXModule } from '../../types'; +import { mdxish } from '../../../lib/mdxish'; +import { type RMDXModule } from '../../../types'; const stubModule: RMDXModule = { default: () => null, diff --git a/lib/constants.ts b/lib/constants.ts new file mode 100644 index 000000000..1d721f5c2 --- /dev/null +++ b/lib/constants.ts @@ -0,0 +1,5 @@ +/** + * Pattern to match component tags (PascalCase or snake_case) + */ +export const componentTagPattern = /<(\/?[A-Z][A-Za-z0-9_]*)([^>]*?)(\/?)>/g; + diff --git a/lib/mdxish.ts b/lib/mdxish.ts index ff7330246..c1b2eec7e 100644 --- a/lib/mdxish.ts +++ b/lib/mdxish.ts @@ -26,7 +26,7 @@ import magicBlockRestorer from '../processor/transform/mdxish/mdxish-magic-block 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'; +import restoreSnakeCaseComponentNames from '../processor/transform/mdxish/restore-snake-case-component-name'; import variablesTextTransformer from '../processor/transform/mdxish/variables-text'; import tailwindTransformer from '../processor/transform/tailwind'; diff --git a/lib/mdxishTags.ts b/lib/mdxishTags.ts index e2661c2e0..3776bd863 100644 --- a/lib/mdxishTags.ts +++ b/lib/mdxishTags.ts @@ -6,6 +6,7 @@ import { visit } from 'unist-util-visit'; import mdxishComponentBlocks from '../processor/transform/mdxish/mdxish-component-blocks'; import { isMDXElement } from '../processor/utils'; +import { componentTagPattern } from './constants'; import { extractMagicBlocks } from './utils/extractMagicBlocks'; const tags = (doc: string) => { @@ -16,7 +17,7 @@ const tags = (doc: string) => { const tree = processor.parse(sanitizedDoc); visit(processor.runSync(tree), isMDXElement, (node: MdxJsxFlowElement | MdxJsxTextElement) => { - if (node.name?.match(/^[A-Z][A-Za-z_]*$/)) { + if (node.name?.match(componentTagPattern)) { set.add(node.name); } }); diff --git a/processor/transform/mdxish/mdxish-snake-case-components.ts b/processor/transform/mdxish/mdxish-snake-case-components.ts index 1fbc39b49..88676962e 100644 --- a/processor/transform/mdxish/mdxish-snake-case-components.ts +++ b/processor/transform/mdxish/mdxish-snake-case-components.ts @@ -1,3 +1,5 @@ +import { componentTagPattern } from '../../../lib/constants'; + export type SnakeCaseMapping = Record; export interface SnakeCasePreprocessResult { @@ -20,8 +22,6 @@ export function processSnakeCaseComponent(content: string): SnakeCasePreprocessR const reverseMap = new Map(); let counter = 0; - const componentTagPattern = /<(\/?[A-Z][A-Za-z0-9_]*)([^>]*?)(\/?)>/g; - const processedContent = content.replace(componentTagPattern, (match, tagName, attrs, selfClosing) => { if (!tagName.includes('_')) { return match; diff --git a/processor/transform/mdxish/restore-snake-case-component-name.ts.ts b/processor/transform/mdxish/restore-snake-case-component-name.ts similarity index 100% rename from processor/transform/mdxish/restore-snake-case-component-name.ts.ts rename to processor/transform/mdxish/restore-snake-case-component-name.ts From 8daf5ba05b72e68a6c178952ef2a15245e1e8ad4 Mon Sep 17 00:00:00 2001 From: Jadenzzz <94533693+Jadenzzz@users.noreply.github.com> Date: Wed, 7 Jan 2026 14:52:00 +1100 Subject: [PATCH 7/7] fix tests --- lib/mdxishTags.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/mdxishTags.ts b/lib/mdxishTags.ts index 3776bd863..313db5920 100644 --- a/lib/mdxishTags.ts +++ b/lib/mdxishTags.ts @@ -6,7 +6,7 @@ import { visit } from 'unist-util-visit'; import mdxishComponentBlocks from '../processor/transform/mdxish/mdxish-component-blocks'; import { isMDXElement } from '../processor/utils'; -import { componentTagPattern } from './constants'; + import { extractMagicBlocks } from './utils/extractMagicBlocks'; const tags = (doc: string) => { @@ -17,7 +17,7 @@ const tags = (doc: string) => { const tree = processor.parse(sanitizedDoc); visit(processor.runSync(tree), isMDXElement, (node: MdxJsxFlowElement | MdxJsxTextElement) => { - if (node.name?.match(componentTagPattern)) { + if (node.name?.match(/^[A-Z]/)) { set.add(node.name); } });