diff --git a/cli/__tests__/convertToMDX.test.ts b/cli/__tests__/convertToMDX.test.ts index 1604b56..907997b 100644 --- a/cli/__tests__/convertToMDX.test.ts +++ b/cli/__tests__/convertToMDX.test.ts @@ -113,6 +113,150 @@ it('reuses repeated imports and disambiguates filenames in different directories expect(converted).toContain('') }) +it.each([ + // [description, importLine, shouldBeRemoved] + ['default absolute import', "import Chatbot from '@patternfly/chatbot/dist/dynamic/Chatbot'", true], + ['named absolute import', "import { Foo } from '@patternfly/react-core'", true], + ['aliased named absolute import', "import { Foo as Bar } from '@patternfly/react-core'", true], + ['multiple named absolute imports', "import { Foo, Bar } from '@patternfly/react-core'", true], + ['compound default and named absolute import', "import Chatbot, { ChatbotDisplayMode } from '@patternfly/chatbot/dist/dynamic/Chatbot'", true], + ['semicolon-terminated absolute import', "import Chatbot from '@patternfly/chatbot/dist/dynamic/Chatbot';", true], + ['default type-only absolute import', "import type Foo from '@patternfly/some-package'", true], + ['named type-only absolute import', "import type { Bar } from '@patternfly/some-package'", true], + ['relative import', "import Kept from './Kept'", false], + ['parent-relative import', "import Kept from '../Kept'", false], + ['relative named import', "import { Kept } from './Kept'", false], + ['absolute CSS import', "import '@patternfly/react-core/dist/styles/base.css'", false], +])('import removal: %s', async (_desc, importLine, shouldBeRemoved) => { + ;(glob as unknown as jest.Mock).mockResolvedValue(['test.md']) + ;(readFile as jest.Mock).mockResolvedValue(`${importLine}\n# Heading`) + + await convertToMDX('test.md') + + const converted = (writeFile as jest.Mock).mock.calls[0][1] + if (shouldBeRemoved) { + expect(converted).not.toContain(importLine) + } else { + expect(converted).toContain(importLine) + } +}) + +it.each<[string, string, (converted: string) => void]>([ + // [limitation, input, assertion of CURRENT (imperfect) behavior] + // These rows document accepted gaps, not desired behavior. None are reachable in the + // currently audited docs. Fixing most of them requires fence-masking (running the + // transforms only on unfenced segments); flipping a row is a deliberate contract change. + [ + 'absolute imports inside code fences are stripped, corrupting the sample', + "```js\nimport { Button } from '@patternfly/react-core';\nconst x = 1;\n```", + (converted) => { + expect(converted).not.toContain("import { Button } from '@patternfly/react-core';") + expect(converted).toContain('const x = 1;') + }, + ], + [ + 'empty file= fences nested inside outer demo fences are still converted', + '````md\n```ts file="./Example.tsx"\n```\n````', + (converted) => { + expect(converted).toContain('import Example from "./Example.tsx?raw"') + expect(converted).toContain('') + }, + ], + [ + 'HTML comments inside code fences are rewritten to MDX comments', + '```\n\n```', + (converted) => expect(converted).toContain('{/* note */}'), + ], + [ + 'namespace absolute imports are preserved', + "import * as React from 'react'\n# Heading", + (converted) => expect(converted).toContain("import * as React from 'react'"), + ], + [ + 'bound absolute CSS imports with a ?url suffix are stripped', + "import styles from '@patternfly/react-core/dist/styles/base.css?url'\n
", + (converted) => { + expect(converted).not.toContain("import styles from '@patternfly/react-core/dist/styles/base.css?url'") + expect(converted).toContain('
') + }, + ], + [ + 'bound absolute CSS imports with a ?inline suffix are stripped', + "import styles from '@patternfly/react-core/dist/styles/base.css?inline'\n
", + (converted) => { + expect(converted).not.toContain("import styles from '@patternfly/react-core/dist/styles/base.css?inline'") + expect(converted).toContain('
') + }, + ], +])('known limitation (documents current behavior; changing it is a deliberate decision): %s', async (_desc, input, assertCurrentBehavior) => { + ;(glob as unknown as jest.Mock).mockResolvedValue(['test.md']) + ;(readFile as jest.Mock).mockResolvedValue(input) + + await convertToMDX('test.md') + + const converted = (writeFile as jest.Mock).mock.calls[0][1] + assertCurrentBehavior(converted) +}) + +it('removes multi-line compound absolute imports', async () => { + const importBlock = `import ChatbotHeader, { + ChatbotHeaderMain, + ChatbotHeaderActions, +} from '@patternfly/chatbot/dist/dynamic/ChatbotHeader'` + ;(glob as unknown as jest.Mock).mockResolvedValue(['test.md']) + ;(readFile as jest.Mock).mockResolvedValue(`${importBlock}\n# Heading`) + + await convertToMDX('test.md') + + const converted = (writeFile as jest.Mock).mock.calls[0][1] + expect(converted).not.toContain('import ChatbotHeader') + expect(converted).not.toContain('ChatbotHeaderMain') + expect(converted).toContain('# Heading') +}) + +it.each([ + ['LF', '\n'], + ['CRLF', '\r\n'], +])('removes combined imports before generating colliding example imports with %s line endings', async (_lineEndingName, lineEnding) => { + const mockContent = [ + "import Chatbot, { ChatbotDisplayMode } from '@patternfly/chatbot/dist/dynamic/Chatbot'", + '\`\`\`tsx file="./Chatbot.tsx" isFullscreen', + '\`\`\`', + '\`\`\`tsx file="./ChatbotDisplayMode.tsx"', + '\`\`\`', + ].join(lineEnding) + ;(glob as unknown as jest.Mock).mockResolvedValue(['test.md']) + ;(readFile as jest.Mock).mockResolvedValue(mockContent) + + await convertToMDX('test.md') + + const converted = (writeFile as jest.Mock).mock.calls[0][1] + expect(converted).not.toContain("import Chatbot, { ChatbotDisplayMode } from '@patternfly/chatbot/dist/dynamic/Chatbot'") + expect(converted).toContain('import Chatbot from "./Chatbot.tsx?raw"') + expect(converted).toContain('') + expect(converted).toContain('import ChatbotDisplayMode from "./ChatbotDisplayMode.tsx?raw"') + expect(converted).toContain('') +}) + +it('removes type-only absolute imports so MDX never sees TypeScript-only syntax', async () => { + const mockContent = `import type Foo from '@patternfly/some-package' +import type { Bar } from '@patternfly/some-package' +import Kept from './Kept' +\`\`\`tsx file="./Foo.tsx" +\`\`\`` + ;(glob as unknown as jest.Mock).mockResolvedValue(['test.md']) + ;(readFile as jest.Mock).mockResolvedValue(mockContent) + + await convertToMDX('test.md') + + const converted = (writeFile as jest.Mock).mock.calls[0][1] + expect(converted).not.toContain("import type Foo from '@patternfly/some-package'") + expect(converted).not.toContain("import type { Bar } from '@patternfly/some-package'") + expect(converted).toContain("import Kept from './Kept'") + expect(converted).toContain('import Foo from "./Foo.tsx?raw"') + expect(converted).toContain('') +}) + it('should convert a file with HTML examples', async () => { const mockContent = '# Test Content\n```html\n
Test HTML
\n```' const expectedContent = diff --git a/cli/convertToMDX.ts b/cli/convertToMDX.ts index 5fcae3a..f831450 100644 --- a/cli/convertToMDX.ts +++ b/cli/convertToMDX.ts @@ -76,10 +76,28 @@ function removeNoLiveTags(content: string): string { return content.replace(/```no[lL]ive/g, '```') } +// Binding shapes between `import` and `from` that should be removed. +// Ordered longest-first so the compound form wins before its default-only prefix. +const removableBindingForms = [ + String.raw`[\w$]+\s*,\s*\{[\w$\s,]*\}`, // import Chatbot, { ChatbotDisplayMode } + String.raw`[\w$]+`, // import Chatbot + String.raw`\{[\w$\s,]*\}`, // import { Foo, Bar as Baz } +] + +// Module path that is absolute (not ./ or ../) and not a CSS file +const absoluteNonCssModule = String.raw`['"](?!\.\.?\/)(?!.*\.css['"])[^'"]*['"]` + +// Optional TypeScript-only `type` keyword (e.g. import type Foo from 'pkg') +const optionalTypeKeyword = String.raw`(?:type\s+)?` + +const removableImportRegex = new RegExp( + String.raw`^import ${optionalTypeKeyword}(?:${removableBindingForms.join('|')}) from ${absoluteNonCssModule};?\r?\n`, + 'gm', +) + function removeExistingImports(content: string): string { // Remove imports that are absolute and not CSS - const importRegex = /^import {?[\w\s,\n]*}? from ['"](?!\.\.?\/)(?!.*\.css['"])[^'"]*['"];?\n/gm - return content.replace(importRegex, '') + return content.replace(removableImportRegex, '') } function convertCommentsToMDX(content: string): string {