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,5 @@
- summary: |
Fix images and other local file references in translated pages not rendering. Translated
page content is now run through the same image parse, upload, and file ID replacement
pipeline as default-locale pages.
type: fix
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ import fs from "fs";
import { resolve } from "path";
import { afterEach, beforeEach, vi } from "vitest";

import { parseImagePaths, replaceImagePathsAndUrls } from "../parseImagePaths.js";
import {
parseImagePaths,
replaceImagePathsAndUrls,
replaceImagePathsAndUrlsInTranslatedPage
} from "../parseImagePaths.js";

const CONTEXT = createMockTaskContext();

Expand Down Expand Up @@ -1726,3 +1730,37 @@ describe("angle bracket delimited destinations", () => {
expect(result.trim()).toBe("[other page](</docs/other>)");
});
});

describe("replaceImagePathsAndUrlsInTranslatedPage", () => {
const TRANSLATED_MDX_PATH = AbsoluteFilePath.of("/Volume/git/fern/translations/tr/my/docs/folder/file.mdx");
const IMAGE_PATH = AbsoluteFilePath.of("/Volume/git/fern/my/docs/folder/path/to/image.png");

function replace(markdown: string, fileIds: Map<AbsoluteFilePath, string> = new Map([[IMAGE_PATH, "fileID"]])) {
return replaceImagePathsAndUrlsInTranslatedPage({
markdown,
fileIdsMap: fileIds,
markdownFilesToPathName: {},
absolutePathToFernFolder: DOCS_PATH,
absolutePathToDefaultLocaleMarkdownFile: MDX_PATH,
absolutePathToTranslatedMarkdownFile: TRANSLATED_MDX_PATH,
context: CONTEXT
}).trim();
}

it("replaces a path authored relative to the translated page", () => {
expect(replace('<img src="../../../my/docs/folder/path/to/image.png" />')).toBe('<img src="file:fileID" />');
});

it("replaces a path copied verbatim from the default-locale page", () => {
expect(replace('<img src="path/to/image.png" />')).toBe('<img src="file:fileID" />');
});

it("leaves a reference with no uploaded file as authored", () => {
expect(replace('<img src="path/to/missing.png" />')).toBe('<img src="path/to/missing.png" />');
});

it("leaves markdown and code includes untouched", () => {
const page = '<Markdown src="../shared/snippet.mdx" />\n<Code src="../shared/example.ts" />';
expect(replace(page)).toBe(page);
});
});
9 changes: 8 additions & 1 deletion packages/cli/docs-markdown-utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,20 @@ export {
isValidRelativeSlug,
parseImagePaths,
replaceImagePathsAndUrls,
replaceImagePathsAndUrlsInTranslatedPage,
trimAnchor
} from "./parseImagePaths.js";
export { parseMarkdownBodyToTree, parseMarkdownToTree } from "./parseMarkdownToTree.js";
export { collectCodeSrcUrls, prefetchCodeSrcUrls, replaceReferencedCode } from "./replaceReferencedCode.js";
export {
collectCodeSrcUrls,
prefetchCodeSrcUrls,
removeCodeIncludeTags,
replaceReferencedCode
} from "./replaceReferencedCode.js";
export {
type ReferencedMarkdownFile,
type ReplaceReferencedMarkdownResult,
removeMarkdownIncludeTags,
replaceReferencedMarkdown
} from "./replaceReferencedMarkdown.js";
export { stripMdxComments } from "./stripMdxComments.js";
Expand Down
37 changes: 37 additions & 0 deletions packages/cli/docs-markdown-utils/src/parseImagePaths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1241,6 +1241,43 @@ export function replaceImagePathsAndUrls(
return requoteLeadingZeroValues(grayMatter.stringify(replacedContent, data));
}

/**
* Replaces image paths in a translated page with the uploaded file IDs.
*
* Translated pages either copy the default-locale page's relative paths verbatim or write them
* relative to their own location under `translations/<locale>/`, so both locations are tried.
* References that resolve to no uploaded file are left as authored.
*/
export function replaceImagePathsAndUrlsInTranslatedPage({
markdown,
fileIdsMap,
markdownFilesToPathName,
absolutePathToFernFolder,
absolutePathToDefaultLocaleMarkdownFile,
absolutePathToTranslatedMarkdownFile,
context
}: {
markdown: string;
fileIdsMap: ReadonlyMap<AbsoluteFilePath, string>;
markdownFilesToPathName: Record<AbsoluteFilePath, string>;
absolutePathToFernFolder: AbsoluteFilePath;
absolutePathToDefaultLocaleMarkdownFile: AbsoluteFilePath;
absolutePathToTranslatedMarkdownFile: AbsoluteFilePath;
context: TaskContext;
}): string {
return [absolutePathToDefaultLocaleMarkdownFile, absolutePathToTranslatedMarkdownFile].reduce(
(replaced, absolutePathToMarkdownFile) =>
replaceImagePathsAndUrls(
replaced,
fileIdsMap,
markdownFilesToPathName,
{ absolutePathToMarkdownFile, absolutePathToFernFolder },
context
),
markdown
);
}

function getPosition(
markdown: string,
position: { start: { line: number; column: number }; end: { line: number; column: number } }
Expand Down
11 changes: 11 additions & 0 deletions packages/cli/docs-markdown-utils/src/replaceReferencedCode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,17 @@ function extractLines(content: string, linesParam: string): string {

const CODE_TAG_REGEX = /([ \t]*)<Code(?:\s+[^>]*?)?\s+src={?['"]([^'"]+)['"](?! \+)}?((?:\s+[^>]*)?)\/>/g;

/**
* Removes `<Code src="..."/>` tags, e.g. so that their references are not mistaken for assets when
* scanning content that has not had its includes resolved yet.
*/
export function removeCodeIncludeTags(markdown: string): string {
if (!markdown.includes("<Code")) {
return markdown;
}
return markdown.replace(new RegExp(CODE_TAG_REGEX.source, CODE_TAG_REGEX.flags), "");
}

/**
* Scans markdown content for <Code src="https://..."/> tags and returns the external URLs.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,19 @@ function substituteVariables(content: string, variables: Record<string, string>)
return result;
}

const MARKDOWN_TAG_REGEX = /([ \t]*)<Markdown\s+([^>]+)\/>/g;

/**
* Removes `<Markdown src="..."/>` tags, e.g. so that their references are not mistaken for assets
* when scanning content that has not had its includes resolved yet.
*/
export function removeMarkdownIncludeTags(markdown: string): string {
if (!markdown.includes("<Markdown")) {
return markdown;
}
return markdown.replace(new RegExp(MARKDOWN_TAG_REGEX.source, MARKDOWN_TAG_REGEX.flags), "");
}

export async function replaceReferencedMarkdown({
markdown,
absolutePathToFernFolder,
Expand All @@ -90,7 +103,7 @@ export async function replaceReferencedMarkdown({
return { markdown, referencedFiles: Array.from(collectedFiles.values()) };
}

const regex = /([ \t]*)<Markdown\s+([^>]+)\/>/g;
const regex = new RegExp(MARKDOWN_TAG_REGEX.source, MARKDOWN_TAG_REGEX.flags);

let newMarkdown = markdown;

Expand Down
18 changes: 8 additions & 10 deletions packages/cli/docs-preview/src/runAppPreviewServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
applyTranslatedNavigationOverlays,
findIncompatibleTranslatedApiIds,
getTranslatedAnnouncement,
replaceImagePathsAndUrls,
replaceImagePathsAndUrlsInTranslatedPage,
replaceReferencedCode,
replaceReferencedMarkdown,
stripMdxComments,
Expand Down Expand Up @@ -910,17 +910,15 @@ export async function runAppPreviewServer({
// Strip MDX comments
let processedMarkdown = stripMdxComments(importsResolved);

// Replace image paths using collected file IDs
processedMarkdown = replaceImagePathsAndUrls(
processedMarkdown,
collectedFileIds,
processedMarkdown = replaceImagePathsAndUrlsInTranslatedPage({
markdown: processedMarkdown,
fileIdsMap: collectedFileIds,
markdownFilesToPathName,
{
absolutePathToMarkdownFile,
absolutePathToFernFolder: docsWorkspacePath
},
absolutePathToFernFolder: docsWorkspacePath,
absolutePathToDefaultLocaleMarkdownFile: absolutePathToMarkdownFile,
absolutePathToTranslatedMarkdownFile: await resolveLocalePath(absolutePathToMarkdownFile),
context
);
});

translatedPages[pagePath] = {
markdown: processedMarkdown,
Expand Down
18 changes: 8 additions & 10 deletions packages/cli/docs-preview/src/runPreviewServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import {
applyTranslatedFrontmatterToNavTree,
applyTranslatedNavigationOverlays,
getTranslatedAnnouncement,
replaceImagePathsAndUrls,
replaceImagePathsAndUrlsInTranslatedPage,
replaceReferencedCode,
replaceReferencedMarkdown,
stripMdxComments,
Expand Down Expand Up @@ -248,17 +248,15 @@ export async function runPreviewServer({
// Strip MDX comments
let processedMarkdown = stripMdxComments(importsResolved);

// Replace image paths using collected file IDs
processedMarkdown = replaceImagePathsAndUrls(
processedMarkdown,
collectedFileIds,
processedMarkdown = replaceImagePathsAndUrlsInTranslatedPage({
markdown: processedMarkdown,
fileIdsMap: collectedFileIds,
markdownFilesToPathName,
{
absolutePathToMarkdownFile,
absolutePathToFernFolder: docsWorkspacePath
},
absolutePathToFernFolder: docsWorkspacePath,
absolutePathToDefaultLocaleMarkdownFile: absolutePathToMarkdownFile,
absolutePathToTranslatedMarkdownFile: await resolveLocalePath(absolutePathToMarkdownFile),
context
);
});

translatedPages[pagePath] = {
markdown: processedMarkdown,
Expand Down
58 changes: 58 additions & 0 deletions packages/cli/docs-resolver/src/DocsDefinitionResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import {
parseImagePaths,
prefetchCodeSrcUrls,
type ReferencedMarkdownFile,
removeCodeIncludeTags,
removeMarkdownIncludeTags,
replaceImagePathsAndUrls,
replaceReferencedCode,
replaceReferencedMarkdown,
Expand Down Expand Up @@ -625,6 +627,7 @@ export class DocsDefinitionResolver {
});
}
}
this.collectImageFilesInTranslationPages(filesToUploadSet);
const imageParseTime = performance.now() - imageParseStart;
this.taskContext.logger.debug(`Parsed image paths in ${imageParseTime.toFixed(0)}ms`);

Expand Down Expand Up @@ -827,6 +830,61 @@ export class DocsDefinitionResolver {
return { config, pages, jsFiles };
}

/**
* Collects assets referenced by translated pages so that images only a translation uses are
* uploaded. The translated markdown itself is rewritten per locale when the translated docs
* definition is built, so it is left untouched here.
*/
private collectImageFilesInTranslationPages(filesToUploadSet: Set<AbsoluteFilePath>): void {
const translationPages = this.parsedDocsConfig.translationPages;
if (translationPages == null) {
return;
}

for (const [locale, pages] of Object.entries(translationPages)) {
for (const [relativePath, rawMarkdown] of Object.entries(pages)) {
const relativeFilePath = RelativeFilePath.of(relativePath);
// `<Markdown src="..."/>` and `<Code src="..."/>` includes are inlined per locale
// downstream, so their references are not assets to upload.
const markdown = removeCodeIncludeTags(removeMarkdownIncludeTags(rawMarkdown));
// Image paths are authored either relative to the translated file or copied verbatim
// from the default-locale page, so both locations are considered.
const filepaths = [
this.resolveTranslationFilepath(locale, relativeFilePath),
this.resolveFilepath(relativeFilePath)
].flatMap(
(absolutePathToMarkdownFile) =>
parseImagePaths(
markdown,
{
absolutePathToMarkdownFile,
absolutePathToFernFolder: this.docsWorkspace.absoluteFilePath
},
this.taskContext
).filepaths
);

for (const filepath of filepaths) {
if (existsSync(filepath)) {
filesToUploadSet.add(filepath);
}
}
}
}
}

/**
* Translated pages live at `translations/<locale>/<relative path of the default-locale page>`.
*/
private resolveTranslationFilepath(locale: string, relativeFilePath: RelativeFilePath): AbsoluteFilePath {
return join(
this.docsWorkspace.absoluteFilePath,
RelativeFilePath.of("translations"),
RelativeFilePath.of(locale),
relativeFilePath
);
}

private resolveFilepath(unresolvedFilepath: string): AbsoluteFilePath;
private resolveFilepath(unresolvedFilepath: string | undefined): AbsoluteFilePath | undefined;
private resolveFilepath(unresolvedFilepath: string | undefined): AbsoluteFilePath | undefined {
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
instances:
- url: https://translation-images.docs.buildwithfern.com
translations:
- lang: en
default: true
- lang: tr
- lang: pt
navigation:
- page: Landing
path: ./pages/landing.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Landing

<img src="../assets/logo.png" alt="" />
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Shared snippet.
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Página inicial

<img src="../assets/logo.png" alt="" />
<img src="../assets/logo-pt.png" alt="" />
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Ana sayfa

<img src="../../../assets/logo.png" alt="" />
<img src="../../../assets/logo-tr.png" alt="" />
<img src="../../../assets/missing.png" alt="" />
<Markdown src="../../../snippets/shared.mdx" />
Loading
Loading