Skip to content

fix(cli): don't rewrite @/ imports inside MDX code blocks - #17406

Open
fern-api[bot] wants to merge 1 commit into
mainfrom
devin/1786657813-fix-at-prefix-imports-code-blocks
Open

fix(cli): don't rewrite @/ imports inside MDX code blocks#17406
fern-api[bot] wants to merge 1 commit into
mainfrom
devin/1786657813-fix-at-prefix-imports-code-blocks

Conversation

@fern-api

@fern-api fern-api Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Description

transformAtPrefixImports resolved @/-prefixed MDX imports with a single markdown.replace() over the raw page, so any line matching import ... from "@/..." was rewritten to a path relative to the MDX file — including lines inside fenced code blocks and inline code. A customer documenting a Next.js app saw their code sample silently mutated:

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

This is not preview-only: the same transform runs on publish (`DocsDefinitionResolver`, `publishDocs`, `buildTranslatedDocsDefinition`), and because `<Code src>`/code includes are resolved *before* it, imports pulled in from real source files were mangled too.

The transform now parses the page (`parseMarkdownToTree`) to collect the byte ranges of `code`/`inlineCode` nodes and skips any match that starts inside one; everything outside code is rewritten exactly as before. If parsing fails, or frontmatter stripping makes offsets unmappable, it falls back to the previous behavior rather than dropping the rewrite.

## Changes Made
- `transformAtPrefixImports`: skip `@/` import matches located inside fenced code blocks or inline code spans
- Early-out when the page contains no `@/` at all
- Regression tests for a `jsx` fence (with frontmatter and a real page-level import alongside it) and for inline code
- CLI changelog entry (`fix`)

## Testing
- [x] Unit tests added/updated — `pnpm turbo run test --filter @fern-api/docs-markdown-utils` (291 passing); both new tests fail on `main` and pass with this change
- [ ] Manual testing completed

<!-- devin-review-badge-begin -->

---

<a href="https://app.devin.ai/review/fern-api/fern/pull/17406" target="_blank">
  <picture>
    <source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
    <img src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1" alt="Open in Devin Review">
  </picture>
</a>
<!-- devin-review-badge-end -->

@nitpickybot nitpickybot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

AI Review Summary

Solid targeted fix: parse the page, collect code/inlineCode ranges, skip regex matches that land inside them. Main concern is that grayMatter() is called outside the try/catch, so a page with malformed YAML frontmatter would now throw where it previously didn't — a publish-path regression. Minor readability nit on the visit test predicate.

  • 🟡 1 warning(s)
  • 🔵 2 suggestion(s)

Comment on lines +26 to +34
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);

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);

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

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"],

const bodyOffset = markdown.length - content.length;

try {
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.

@devin-ai-integration devin-ai-integration Bot left a comment

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.

Devin Review found 2 potential issues.

View 1 additional finding in Devin Review.

Open in Devin Review

Comment on lines +52 to +54
} catch {
return undefined;
}

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.

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

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants