Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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,6 @@
- summary: |
Fix local images and links being published as build-machine filesystem paths (e.g.
`/home/runner/work/.../assets/image.png`) when an earlier line on the page contains a literal
`<` in prose or inline code. The markdown scanner treated any `<` as the start of a tag and
skipped ahead to the next `>`, dropping every image and link substitution in between.
type: fix
Original file line number Diff line number Diff line change
Expand Up @@ -1546,3 +1546,62 @@ describe("markdown image titles", () => {
expect(result.trim()).toBe("[other page](/docs/other#section 'My title')");
});
});

describe("literal angle brackets in prose", () => {
const IMAGE_PATH = AbsoluteFilePath.of("/Volume/git/fern/my/docs/folder/path/to/image.png");
const fileIds = new Map([[IMAGE_PATH, "leaf-id"]]);

function roundTrip(page: string): string {
const parsed = parseImagePaths(page, PATHS, CONTEXT);
return replaceImagePathsAndUrls(parsed.markdown, fileIds, {}, PATHS, CONTEXT);
}

it.each([
["comparison operator in inline code", "Outliers are `is < Q1 - 1.5*IQR`."],
["comparison operator in plain text", "Keep the tolerance < 5 percent."],
["less-than-or-equal in inline code", "Show deals below the margin (filter is `<=`)."],
["escaped angle bracket", "Use \\<placeholder\\> for the name."],
["angle bracket inside a fenced code block", "```js\nif (a < b) {\n send();\n}\n```"]
])("replaces a later image path when the page contains a %s", (_name, prose) => {
const page = `${prose}\n\n![leaf](path/to/image.png)\n`;
expect(roundTrip(page).trim()).toBe(`${prose}\n\n![leaf](file:leaf-id)`.trim());
});

it("replaces images that follow an unterminated tag-like construct", () => {
const page = "Pass `<div` to the helper.\n\n![leaf](path/to/image.png)\n";
expect(roundTrip(page)).toContain("file:leaf-id");
});

it("still rewrites src on real tags", () => {
const page = 'The width must be < 100.\n\n<img src="path/to/image.png" />\n';
const result = roundTrip(page);
expect(result).toContain('src="file:leaf-id"');
expect(result).toContain("must be < 100");
});

it("still rewrites links after a literal angle bracket", () => {
const page = "Values where a < b.\n\n[other page](./other.mdx)\n";
const result = replaceImagePathsAndUrls(
page,
new Map(),
{ [AbsoluteFilePath.of("/Volume/git/fern/my/docs/folder/other.mdx")]: "docs/other" },
PATHS,
CONTEXT
);
expect(result).toContain("[other page](/docs/other)");
});

it("replaces the image path on both the streaming and AST paths", () => {
vi.stubEnv("FERN_DOCS_LARGE_FILE_BYTES", "10");
const page = "Outliers are `is < Q1`.\n\n![leaf](path/to/image.png)\n";
const parsed = parseImagePaths(page, PATHS, CONTEXT);
expect(parsed.filepaths).toEqual([IMAGE_PATH]);
expect(replaceImagePathsAndUrls(parsed.markdown, fileIds, {}, PATHS, CONTEXT)).toContain("file:leaf-id");
vi.unstubAllEnvs();
});

it("does not leave a local filesystem path in the published markdown", () => {
const page = "Outliers are `is < Q1`.\n\n![leaf](path/to/image.png)\n";
expect(roundTrip(page)).not.toContain("/Volume/git/fern");
});
});
135 changes: 103 additions & 32 deletions packages/cli/docs-markdown-utils/src/parseImagePaths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,30 @@ interface ImageOccurrence {
type: "markdown-image" | "markdown-link" | "jsx-src" | "jsx-href";
}

const JSX_TAG_NAME_START_REGEX = /[A-Za-z]/;

/**
* A `<` only opens a tag when a tag name (or `/`) follows it and it isn't escaped. Comparisons in
* prose such as `a < b`, `<=`, or `\<` are literal text: scanning them as tags makes the scan run
* to the next `>` anywhere in the page, silently skipping every image and link in between.
*/
function isJsxTagStart(content: string, index: number): boolean {
if (content[index] !== "<" || content[index - 1] === "\\") {
return false;
}
const nameStart = content[index + 1] === "/" ? content[index + 2] : content[index + 1];
return nameStart != null && JSX_TAG_NAME_START_REGEX.test(nameStart);
}

/**
* Tags never span a blank line, so the scan is bounded there. Without a bound, an unterminated `<`
* consumes the remainder of the page.
*/
function findJsxTagScanLimit(content: string, start: number): number {
const blankLine = content.indexOf("\n\n", start);
return blankLine === -1 ? content.length : blankLine;
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🟡 Windows-style line endings defeat the new safety bound, so a stray angle bracket can again skip images

The new bound that stops a tag scan at the next empty line looks only for two consecutive Unix newlines (content.indexOf("\n\n", start) at packages/cli/docs-markdown-utils/src/parseImagePaths.ts:82), so on pages saved with Windows line endings the scan runs to the end of the page and the images and links it passes over keep their build-machine paths.
Impact: Docs pages authored with Windows line endings can still publish local filesystem paths and broken images when a tag-like < is left unterminated.

Mechanism

findJsxTagScanLimit is the only guard preventing an unterminated tag-like < from consuming the rest of the document, and it is used by both passes (packages/cli/docs-markdown-utils/src/parseImagePaths.ts:305 and :897). With CRLF content, blank lines are \r\n\r\n, which does not contain \n\n, so indexOf returns -1 and the limit becomes content.length. Nothing in this package normalizes line endings (no CRLF handling exists in packages/cli/docs-markdown-utils/src). Matching /\n[ \t]*\r?\n/ (or normalizing \r) would restore the bound.

Suggested change
function findJsxTagScanLimit(content: string, start: number): number {
const blankLine = content.indexOf("\n\n", start);
return blankLine === -1 ? content.length : blankLine;
}
function findJsxTagScanLimit(content: string, start: number): number {
const blankLine = /\n[ \t]*\r?\n/.exec(content.slice(start));
return blankLine == null ? content.length : start + blankLine.index;
}
Open in Devin Review

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 10d466b — the bound (now findScanLimit, shared by the tag scan and the inline-code scan) matches / [ \t]*\r? /, so CRLF and whitespace-only blank lines are handled. Added a CRLF regression case.


function streamingScanForImages(
content: string,
metadata: AbsolutePathMetadata
Expand Down Expand Up @@ -273,36 +297,43 @@ function parseJsxTag(
filepaths: Set<AbsoluteFilePath>,
edits: Edit[]
): { nextIndex: number } | null {
if (!isJsxTagStart(content, start)) {
return null;
}

let i = start + 1;
const len = content.length;
const limit = findJsxTagScanLimit(content, start);
// Buffered so a `<` that turns out not to be a tag leaves no edits behind.
const tagFilepaths: AbsoluteFilePath[] = [];
const tagEdits: Edit[] = [];

while (i < len && content[i] !== ">" && content[i] !== " " && content[i] !== "\n") {
while (i < limit && content[i] !== ">" && content[i] !== " " && content[i] !== "\n") {
i++;
}

while (i < len && content[i] !== ">") {
while (i < len && (content[i] === " " || content[i] === "\n")) {
while (i < limit && content[i] !== ">") {
while (i < limit && (content[i] === " " || content[i] === "\n")) {
i++;
}

const attrStart = i;
while (i < len && content[i] !== "=" && content[i] !== ">" && content[i] !== " " && content[i] !== "\n") {
while (i < limit && content[i] !== "=" && content[i] !== ">" && content[i] !== " " && content[i] !== "\n") {
i++;
}

const attrName = content.slice(attrStart, i).trim();

if (content[i] === "=") {
i++;
while (i < len && (content[i] === " " || content[i] === "\n")) {
while (i < limit && (content[i] === " " || content[i] === "\n")) {
i++;
}

if (content[i] === '"' || content[i] === "'") {
const quote = content[i];
i++;
const valueStart = i;
while (i < len && content[i] !== quote) {
while (i < limit && content[i] !== quote) {
if (content[i] === "\\") {
i += 2;
} else {
Expand All @@ -316,15 +347,15 @@ function parseJsxTag(
const src = trimAnchor(value);
const resolvedPath = resolvePath(src, metadata);
if (src && resolvedPath) {
filepaths.add(resolvedPath);
edits.push({ start: valueStart, end: valueStart + value.length, replacement: resolvedPath });
tagFilepaths.push(resolvedPath);
tagEdits.push({ start: valueStart, end: valueStart + value.length, replacement: resolvedPath });
}
}
} else if (content[i] === "{") {
i++;
let braceDepth = 1;
const exprStart = i;
while (i < len && braceDepth > 0) {
while (i < limit && braceDepth > 0) {
if (content[i] === "{") {
braceDepth++;
} else if (content[i] === "}") {
Expand All @@ -340,8 +371,8 @@ function parseJsxTag(
const src = trimAnchor(value);
const resolvedPath = resolvePath(src, metadata);
if (src && resolvedPath) {
filepaths.add(resolvedPath);
edits.push({
tagFilepaths.push(resolvedPath);
tagEdits.push({
start: exprStart + 1,
end: exprStart + 1 + value.length,
replacement: resolvedPath
Expand All @@ -354,8 +385,8 @@ function parseJsxTag(
const src = trimAnchor(value);
const resolvedPath = resolvePath(src, metadata);
if (src && resolvedPath) {
filepaths.add(resolvedPath);
edits.push({
tagFilepaths.push(resolvedPath);
tagEdits.push({
start: exprStart + 1,
end: exprStart + 1 + value.length,
replacement: resolvedPath
Expand All @@ -367,9 +398,15 @@ function parseJsxTag(
}
}

if (i < len && content[i] === ">") {
i++;
if (i >= limit || content[i] !== ">") {
return null;
}
i++;

for (const filepath of tagFilepaths) {
filepaths.add(filepath);
}
edits.push(...tagEdits);

return { nextIndex: i };
}
Expand Down Expand Up @@ -765,8 +802,32 @@ export function replaceImagePathsAndUrls(
let hasUnhandledExpressions = false;
let i = 0;
const len = content.length;
let inCodeFence = false;
let inInlineCode = false;

while (i < len) {
if ((i === 0 || content[i - 1] === "\n") && content.slice(i, i + 3) === "```") {
inCodeFence = !inCodeFence;
i += 3;
continue;
}

if (inCodeFence) {
i++;
continue;
}

if (content[i] === "`" && content[i - 1] !== "\\") {
inInlineCode = !inInlineCode;
i++;
continue;
}

if (inInlineCode) {
i++;
continue;
}
Comment on lines +877 to +885

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🔴 A single unmatched backtick on a page stops every later image and link from being rewritten

The rewriting pass now treats everything after an unmatched backtick as inline code (inInlineCode = !inInlineCode at packages/cli/docs-markdown-utils/src/parseImagePaths.ts:820-824) with no reset at line or paragraph boundaries, so images and links later on the page keep their build-machine file paths.
Impact: Pages containing an odd number of backticks (for example an indented code block whose contents include a stray backtick) publish local filesystem paths and show broken images — the same failure this change is meant to fix, just triggered by a backtick instead of <.

How the inline-code state leaks across the whole document

Pass 2 (replaceImagePathsAndUrls) now toggles inInlineCode on every unescaped backtick and, while it is set, skips all image/link/JSX handling (packages/cli/docs-markdown-utils/src/parseImagePaths.ts:826-829). The state is never reset at a newline or blank line, and the fence detector at packages/cli/docs-markdown-utils/src/parseImagePaths.ts:809-813 only recognizes at the very start of a line, so fences indented inside list items are not recognized as fences and their backtick contents feed the inline-code toggle instead. Any document whose "inline" backtick count is odd (unmatched backtick in prose, a lone backtick inside an indented fence, or a `~~~`-delimited block containing) ends up permanently in the inline-code state.

Because pass 1 for normal-sized files uses the mdast path, it correctly rewrites image sources to absolute paths; pass 2 then silently skips the substitution, and the absolute path is written into the published markdown. Per CommonMark, code spans cannot contain a blank line and unmatched backticks are literal text, so the scanner should at minimum clear inInlineCode at blank lines (and ideally at end of line).

Prompt for agents
In replaceImagePathsAndUrls (packages/cli/docs-markdown-utils/src/parseImagePaths.ts), the newly added inline-code tracking toggles inInlineCode on every unescaped backtick and never resets it. A document with an odd number of such backticks (unmatched backtick in prose, or backticks inside a fence indented within a list item, which the line-start-only ``` detector does not recognize as a fence) leaves the scanner permanently in the inline-code state, so every subsequent image and link substitution is skipped and the absolute filesystem path inserted by pass 1 is published verbatim. Per CommonMark, a code span cannot contain a blank line and unmatched backticks are literal text, so the state must be bounded: clear inInlineCode when a blank line (paragraph boundary) is reached, and consider clearing it at end of line, or match backtick runs (open with N backticks, close only with the same run length). The same unbounded state exists in streamingScanForImages (packages/cli/docs-markdown-utils/src/parseImagePaths.ts:94-136) and should be fixed consistently so both passes agree.
Open in Devin Review

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Valid — fixed in 10d466b. Both passes no longer carry inInlineCode/inCodeFence state at all:

  • findInlineCodeEnd matches backtick runs (open with N, close only on a run of exactly N) and is bounded at the next blank line; an unmatched backtick is treated as literal text and the scan continues from the next character, so it can't leak across the document.
  • findCodeFenceEnd finds the actual closing fence line for a ``` or ~~~ run, allows leading indentation (so fences inside list items are recognized), and returns null for an unterminated fence rather than swallowing the rest of the page.

New tests: unmatched backtick in prose, backtick inside an indented fence, unterminated fence, plus one asserting an image inside a real fence is still left alone.


if (content[i] === "!" && content[i + 1] === "[") {
const result = parseMarkdownImage(content, i, metadata);
if (result) {
Expand Down Expand Up @@ -807,6 +868,10 @@ export function replaceImagePathsAndUrls(
j++;
}
}
if (parenDepth !== 0) {
i++;
continue;
}
const urlEnd = j - 1;
const href = content.slice(urlStart, urlEnd).trim();
const destination = splitDestinationAndTitle(href);
Expand All @@ -828,18 +893,21 @@ export function replaceImagePathsAndUrls(
i = j;
continue;
}
} else if (content[i] === "<") {
} else if (isJsxTagStart(content, i)) {
const limit = findJsxTagScanLimit(content, i);
// Edits collected while scanning are discarded unless the tag is properly terminated.
const editsBeforeTag = edits.length;
let j = i + 1;
while (j < len && content[j] !== ">" && content[j] !== " " && content[j] !== "\n") {
while (j < limit && content[j] !== ">" && content[j] !== " " && content[j] !== "\n") {
j++;
}
while (j < len && content[j] !== ">") {
while (j < len && (content[j] === " " || content[j] === "\n")) {
while (j < limit && content[j] !== ">") {
while (j < limit && (content[j] === " " || content[j] === "\n")) {
j++;
}
const attrStart = j;
while (
j < len &&
j < limit &&
content[j] !== "=" &&
content[j] !== ">" &&
content[j] !== " " &&
Expand All @@ -854,7 +922,7 @@ export function replaceImagePathsAndUrls(
// Skip past the closing }
let braceDepth = 0;
j = attrStart;
while (j < len) {
while (j < limit) {
if (content[j] === "{") {
braceDepth++;
} else if (content[j] === "}") {
Expand All @@ -866,7 +934,7 @@ export function replaceImagePathsAndUrls(
} else if (content[j] === '"' || content[j] === "'") {
const q = content[j];
j++;
while (j < len && content[j] !== q) {
while (j < limit && content[j] !== q) {
if (content[j] === "\\") {
j++;
}
Expand All @@ -879,23 +947,23 @@ export function replaceImagePathsAndUrls(
}
if (content[j] === "=") {
j++;
while (j < len && (content[j] === " " || content[j] === "\n")) {
while (j < limit && (content[j] === " " || content[j] === "\n")) {
j++;
}
// Handle plain quotes: attr="value" or attr='value'
// Also handle JSX expression: attr={'value'} or attr={"value"}
const isCurlyWrapped = content[j] === "{";
if (isCurlyWrapped) {
j++; // skip {
while (j < len && (content[j] === " " || content[j] === "\n")) {
while (j < limit && (content[j] === " " || content[j] === "\n")) {
j++;
}
}
if (content[j] === '"' || content[j] === "'") {
const quote = content[j];
j++;
const valueStart = j;
while (j < len && content[j] !== quote) {
while (j < limit && content[j] !== quote) {
if (content[j] === "\\") {
j += 2;
} else {
Expand All @@ -905,10 +973,10 @@ export function replaceImagePathsAndUrls(
const value = content.slice(valueStart, j);
j++; // skip closing quote
if (isCurlyWrapped) {
while (j < len && (content[j] === " " || content[j] === "\n")) {
while (j < limit && (content[j] === " " || content[j] === "\n")) {
j++;
}
if (j < len && content[j] === "}") {
if (j < limit && content[j] === "}") {
j++; // skip }
}
}
Expand Down Expand Up @@ -947,15 +1015,15 @@ export function replaceImagePathsAndUrls(
hasUnhandledExpressions = true;
// Skip past the closing }
let braceDepth = 1;
while (j < len && braceDepth > 0) {
while (j < limit && braceDepth > 0) {
if (content[j] === "{") {
braceDepth++;
} else if (content[j] === "}") {
braceDepth--;
} else if (content[j] === '"' || content[j] === "'") {
const q = content[j];
j++;
while (j < len && content[j] !== q) {
while (j < limit && content[j] !== q) {
if (content[j] === "\\") {
j++;
}
Expand All @@ -967,9 +1035,12 @@ export function replaceImagePathsAndUrls(
}
}
}
if (j < len && content[j] === ">") {
j++;
if (j >= limit || content[j] !== ">") {
edits.length = editsBeforeTag;
i++;
continue;
}
j++;
i = j;
continue;
}
Expand Down
Loading