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
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
- summary: |
Fix `@/`-prefixed imports inside fenced code blocks and inline code being rewritten to relative
paths. Code samples are now rendered verbatim; only real MDX imports are resolved.
type: fix
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,56 @@ Some content here.
<Banner />`);
});

it("should not transform imports inside fenced code blocks", () => {
const markdown = `---
title: Tutorial
---

import { Banner } from '@/components/Banner'

Update the imports:

\`\`\`jsx showLineNumbers={false} title="content-fallback.tsx"
import { Flex, ProgressCircle } from "@/components/ui/big-design";
\`\`\`

<Banner />`;
const absolutePathToMarkdownFile = AbsoluteFilePath.of("/path/to/fern/pages/guides/test.mdx");

const result = transformAtPrefixImports({
markdown,
absolutePathToFernFolder,
absolutePathToMarkdownFile
});

expect(result).toBe(`---
title: Tutorial
---

import { Banner } from '../../components/Banner'

Update the imports:

\`\`\`jsx showLineNumbers={false} title="content-fallback.tsx"
import { Flex, ProgressCircle } from "@/components/ui/big-design";
\`\`\`

<Banner />`);
});

it("should not transform imports inside inline code", () => {
const markdown = `Write \`import { Flex } from "@/components/ui/big-design"\` at the top.`;
const absolutePathToMarkdownFile = AbsoluteFilePath.of("/path/to/fern/pages/test.mdx");

const result = transformAtPrefixImports({
markdown,
absolutePathToFernFolder,
absolutePathToMarkdownFile
});

expect(result).toBe(markdown);
});

it("should handle imports in sibling directories", () => {
const markdown = `import { Banner } from '@/docs/components/Banner'`;
const absolutePathToMarkdownFile = AbsoluteFilePath.of("/path/to/fern/pages/guides/test.mdx");
Expand Down
94 changes: 76 additions & 18 deletions packages/cli/docs-markdown-utils/src/transformAtPrefixImports.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,58 @@
import { AbsoluteFilePath, dirname, RelativeFilePath, relative } from "@fern-api/fs-utils";
import grayMatter from "gray-matter";
import { CONTINUE, visit } from "unist-util-visit";
import { parseMarkdownToTree } from "./parseMarkdownToTree.js";

/**
* Match import statements with '@/' prefix
* Handles various import formats:
* - import { X } from '@/path'
* - import X from '@/path'
* - import * as X from '@/path'
* - import '@/path'
*/
const IMPORT_REGEX = /(import\s+(?:(?:\{[^}]*\}|\*\s+as\s+\w+|\w+)\s+from\s+)?['"])@\/([^'"]+)(['"])/g;

interface Range {
start: number;
end: number;
}

/**
* Byte ranges of fenced code blocks and inline code spans. Import statements inside them are
* documentation samples, not MDX imports, and must be rendered verbatim.
*/
function getCodeRanges(markdown: string): Range[] | undefined {
const { content } = grayMatter(markdown);
// `parseMarkdownToTree` strips frontmatter, so node offsets are relative to the body.
if (!markdown.endsWith(content)) {
return undefined;
}
const bodyOffset = markdown.length - content.length;

try {
const tree = parseMarkdownToTree(markdown);
Comment on lines +26 to +34

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 warning

grayMatter() sits outside the try, so malformed YAML frontmatter now throws out of transformAtPrefixImports — a path that previously never parsed anything and couldn't fail. Given this runs on publish (DocsDefinitionResolver, publishDocs), a broken frontmatter block would go from "imports rewritten anyway" to "build blows up". Move it inside the guard.

Suggested change
const { content } = grayMatter(markdown);
// `parseMarkdownToTree` strips frontmatter, so node offsets are relative to the body.
if (!markdown.endsWith(content)) {
return undefined;
}
const bodyOffset = markdown.length - content.length;
try {
const tree = parseMarkdownToTree(markdown);
try {
const { content } = grayMatter(markdown);
// `parseMarkdownToTree` strips frontmatter, so node offsets are relative to the body.
if (!markdown.endsWith(content)) {
return undefined;
}
const bodyOffset = markdown.length - content.length;
const tree = parseMarkdownToTree(markdown);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 suggestion

bodyOffset silently depends on parseMarkdownToTree stripping frontmatter internally. If that ever changes to keep frontmatter as a node (offsets become absolute), every range shifts by the frontmatter length and code blocks get rewritten again — with no test failure signal beyond the one frontmatter test. Consider passing content explicitly (parseMarkdownToTree(content)) so the offset base is unambiguous at this call site.

const ranges: Range[] = [];
visit(
tree,
(node: unknown) => {
const type = (node as { type?: string }).type;
return type === "code" || type === "inlineCode";
},
Comment on lines +36 to +41

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 suggestion

unist-util-visit accepts a type array directly, which drops the unknown cast gymnastics:

Suggested change
visit(
tree,
(node: unknown) => {
const type = (node as { type?: string }).type;
return type === "code" || type === "inlineCode";
},
visit(
tree,
["code", "inlineCode"],

Comment on lines +36 to +41

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Type assertion used where the repository forbids it

The node passed to the traversal check is force-cast to a shape (node as { type?: string } at packages/cli/docs-markdown-utils/src/transformAtPrefixImports.ts:38-40) instead of being typed, which the repository conventions disallow.
Impact: None at runtime; it breaks an explicit code convention of the project.

REVIEW.md: "No `as X` type assertions unless the compiler genuinely cannot infer the type"

The assertion is only needed because the predicate parameter is annotated unknown. unist-util-visit accepts a test as a string or array of node type strings, e.g. visit(tree, ["code", "inlineCode"], (node) => { ... }), which removes the need for both the unknown annotation and the cast, and matches how other code in the repo tests node types.

Suggested change
visit(
tree,
(node: unknown) => {
const type = (node as { type?: string }).type;
return type === "code" || type === "inlineCode";
},
visit(
tree,
["code", "inlineCode"],
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

(node) => {
const start = node.position?.start.offset;
const end = node.position?.end.offset;
if (start != null && end != null) {
ranges.push({ start: start + bodyOffset, end: end + bodyOffset });
}
return CONTINUE;
}
);
return ranges;
} catch {
return undefined;
}
Comment on lines +52 to +54

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Parsing failures are swallowed with no logging

Errors from parsing the page are discarded by an empty catch block (catch {} at packages/cli/docs-markdown-utils/src/transformAtPrefixImports.ts:52-54), which the repository conventions forbid, so when a page fails to parse the code-block protection silently disappears with no diagnostic.
Impact: Users see code samples silently rewritten again on unparseable pages with no clue why.

Empty catch violates REVIEW.md TypeScript convention

REVIEW.md states: "No empty catch blocks -- at minimum log the error." Here getCodeRanges swallows any error thrown by parseMarkdownToTree (packages/cli/docs-markdown-utils/src/parseMarkdownToTree.ts:11-17), returns undefined, and the caller then falls back to rewriting every match, including matches inside fenced code. At minimum the error should be logged/surfaced.

Prompt for agents
REVIEW.md forbids empty catch blocks. In getCodeRanges (packages/cli/docs-markdown-utils/src/transformAtPrefixImports.ts), the catch around parseMarkdownToTree discards the error entirely. Either log the parse failure (console.warn or the package's task-context logger if one is available at the call site) or thread a logger/taskContext through so the silent fallback to the old, buggy rewrite behavior is observable.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

}

/**
* Transforms import statements with '@/' prefix to relative paths.
Expand All @@ -25,28 +79,32 @@ export function transformAtPrefixImports({
absolutePathToFernFolder: AbsoluteFilePath;
absolutePathToMarkdownFile: AbsoluteFilePath;
}): string {
// Match import statements with '@/' prefix
// Handles various import formats:
// - import { X } from '@/path'
// - import X from '@/path'
// - import * as X from '@/path'
// - import '@/path'
const importRegex = /(import\s+(?:(?:\{[^}]*\}|\*\s+as\s+\w+|\w+)\s+from\s+)?['"])@\/([^'"]+)(['"])/g;
if (!markdown.includes("@/")) {
return markdown;
}

const mdxDir = dirname(absolutePathToMarkdownFile);
const codeRanges = getCodeRanges(markdown);

return markdown.replace(importRegex, (match, prefix, importPath, suffix) => {
// Compute the absolute path of the imported file (from fern folder root)
const absoluteImportPath = AbsoluteFilePath.of(`${absolutePathToFernFolder}/${importPath}`);
return markdown.replace(
IMPORT_REGEX,
(match, prefix: string, importPath: string, suffix: string, offset: number) => {
if (codeRanges?.some((range) => offset >= range.start && offset < range.end)) {
return match;
}

// Compute the relative path from the MDX file's directory to the imported file
let relativePath = relative(mdxDir, absoluteImportPath);
// Compute the absolute path of the imported file (from fern folder root)
const absoluteImportPath = AbsoluteFilePath.of(`${absolutePathToFernFolder}/${importPath}`);

// Ensure the path starts with './' or '../' for proper module resolution
if (!relativePath.startsWith(".") && !relativePath.startsWith("/")) {
relativePath = RelativeFilePath.of(`./${relativePath}`);
}
// Compute the relative path from the MDX file's directory to the imported file
let relativePath = relative(mdxDir, absoluteImportPath);

// Ensure the path starts with './' or '../' for proper module resolution
if (!relativePath.startsWith(".") && !relativePath.startsWith("/")) {
relativePath = RelativeFilePath.of(`./${relativePath}`);
}

return `${prefix}${relativePath}${suffix}`;
});
return `${prefix}${relativePath}${suffix}`;
}
);
}