Skip to content

fix(portal): load an <img> in an .mdx page from beside the page and under a path - #456

Merged
saeedjamshaid merged 6 commits into
devfrom
saeedjamshaid/mdx-raw-images
Oct 27, 2026
Merged

saeedjamshaid merged 6 commits into
devfrom
saeedjamshaid/mdx-raw-images

Conversation

@saeedjamshaid

@saeedjamshaid saeedjamshaid commented Oct 27, 2026 •

Copy link
Copy Markdown
Collaborator

The problem

An <img> in an .mdx page that names a local file doesn't load:

  • <img src="./flow.png" /> is left as written, so the browser asks for flow.png beside the page's address, where the build never writes it.
  • <img src="/images/logo.png" /> loads at the root of a host. A portal hosted under a path, such as https://docs.example.com/api, serves the file at /api/images/logo.png, so the image is missing.
  • An <img> that names a file that doesn't exist builds without a word and shows a broken image.

A Markdown image, ![Flow](./flow.png), has none of these problems, because Fumadocs bundles it. Fumadocs bundles Markdown images only, and an <img> is the usual way to set an image's width.

Part of apimatic-io issue 2314.

What changes

  • An <img> that names a local file is bundled like a Markdown image.
    • A template remark plugin, remark-jsx-images.ts, imports the file and sets src to the import.
    • It reads src by the rule Markdown images follow: /… from src/static/, anything else from beside the page, without its query or fragment, decoded. As a browser does, it drops the spaces around the address.
    • The image then loads from beside its page and under a path, and its other attributes, such as width and alt, are kept.
    • It leaves alone a remote, // or data: address, src={…}, and an <img> in a .md page, whose HTML the build leaves out.
  • A missing file is reported before the build, with its page and line, in the message Markdown images get. Without the check, the build would fail on the import without naming the page. A page whose <img> names a missing file used to build; it is now refused. That includes an <img> that loaded only because of how the browser resolved its address:
    • images/logo.png on a top-level page, at the root of a host, for static/images/logo.png. Write it as /images/logo.png.
    • a /… address the portal doesn't serve, such as /api/images/logo.png with the path written by hand, or a file another app serves on the same host. Write the first as /images/logo.png, and the second as a full https:// address.
  • An <img> in a file pulled in with <include> can now stop the build. It is looked for beside the page that includes the file, as a Markdown image there already is. When its file isn't there, the build fails with Could not resolve './inner.png' in content/includer.mdx, where it used to build with a broken image. The CLI doesn't check included files, for either kind of image. Worth a line in the release notes.
  • A test holds the plugin and the CLI's check to the same files, as remark-image-references.test.ts does for reference-style images. The template can't import the CLI's code, so the rule is written in both.

The Markdown copies and llms-full.txt still show the tags as written.

Testing

  • pnpm test with APIMATIC_E2E=1, as CI runs it: 2056 passing, ESLint clean.
  • Unit:
    • the plugin: page-relative and static files, an <img> in a line of text and in a component, the spaces, query, fragment and encoding, each address it leaves alone, a .md page, and, on Windows, a static directory on another drive;
    • the CLI's check: which <img> tags it reads, the spaces around an <img>'s address, which a Markdown image keeps as the build does, and a missing file refused with its page and line;
    • the test that the plugin imports exactly the files the CLI checks, with and without a static directory, and for each address the plugin leaves alone or decodeURI refuses. It fails when the CLI is made to skip an <img> inside a line of text, or the plugin to import a static file with no static directory.
  • End to end: the fixture hosted under /api gains figures.mdx, with an <img> from static/ and one from beside the page. Both load from /api/assets/…, the width is kept, the page names nothing on its host outside /api, and its Markdown copy keeps the tags. In the fixture hosted at the root, an <img> naming a file beside its page is bundled too.
  • Preview: the same fixture served under /api and opened in headless Chrome. All the images load, with no failed requests. Once an <img> naming a missing file is added, the check portal serve runs on a save refuses it, with the page and line.

Not in this PR

  • A .md page's HTML and components: the build still leaves them out without a word. Naming them in a warning is the second PR for apimatic-io issue 2314.
  • An <img> in the generated SDK pages, and other tags that name files: srcset, <picture>'s <source>, <video src>, and a raw <a href="/…">.
  • An <img> written inside a prop, such as <Card icon={<img src="./a.png" />} />. It's code rather than a tag of the page, so neither the plugin nor the check sees it, and it is left as written, as before.
  • An <img> keeps its own look. A Markdown image gets the portal's rounded corners and responsive sizes; an <img> doesn't, as before.
  • An SVG fragment is dropped. <img src="/images/icons.svg#check" /> shows the whole SVG instead of its check view.
  • What Markdown images already have, an <img> now has too:
    • a file Vite doesn't bundle as an asset, such as .heic, .tiff or one without an extension, fails the build without naming the page;
    • a static directory on a mapped network drive gives an import the template's static-image resolver doesn't match;
    • on Windows, \images\a.png is refused as pointing outside content/.

🤖 Generated with Claude Code

saeedjamshaid and others added 4 commits October 27, 2026 10:42
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Moves the plugin to the template root, beside the other build modules that use Node's APIs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@mehnoorsiddiqui mehnoorsiddiqui 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.

Thanks, this is a solid fix. I tested it on Windows:

  • the full suite with APIMATIC_E2E=1;
  • a 24-case .mdx page built under /api and loaded in headless Chrome, with every request logged;
  • vite dev, including an <img> and its file added while the server runs;
  • a static directory on another drive;
  • a check of which file the plugin and the CLI pick for about 60 tricky addresses: the two agreed every time.

Every local <img> loads from /api/assets/…, the attributes are kept, and the Markdown copies stay as written.

One change before merge (inline on page.ts): the new trim() also applies to Markdown images, so the CLI now accepts a Markdown image the build then fails on.

Not inline, worth deciding on:

  1. An <img> in an .mdx file pulled in with <include> now fails the whole build. The CLI check doesn't catch it. On dev the same page built with a broken image; here the build stops with Could not resolve './inner.png' in content/includer.mdx. It's listed under "Not in this PR", but for these portals it changes a broken image into a failed build. Either check included files in the CLI, or call it out in the release notes.
  2. An <img> inside a component's prop isn't covered, e.g. <Card icon={<img src="./a.png" />} />. Neither the plugin nor the check sees it, so it still 404s under a path. Worth adding to "Not in this PR".
  3. Root-hosted builds: the issue's "How to verify" says images should load "under a path and at the root". Only the /api fixture covers <img> tags now. A root build worked when I tried it by hand, but nothing tests it.
  4. Commit 2082c49d both fixes the trim and moves the file, which makes it harder to bisect. Minor.

The other inline comments are small maintainability suggestions; none of them blocks the merge.

Comment thread src/types/portal/page.ts Outdated
Comment on lines +162 to +163
// A browser drops the spaces around an `<img>`'s address.
const address = url.trim().split(/[?#]/, 1)[0];

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.

The trim now applies to Markdown images too, and Fumadocs' remarkImage doesn't trim. It calls parseSrc(decodeURI(node.url), …) as written.

Here's what happens with ![x](< ./flow.png >) in a .md page, where the spaces inside the angle brackets are part of the URL:

  • On dev, the CLI refuses the page and names it.
  • With this PR, the CLI accepts it, and then the build fails with [UNRESOLVED_IMPORT] Could not resolve './ ./flow.png ' in content/trim.md.

I suggest trimming only the <img> address, for example return node.name === 'img' && typeof src === 'string' ? src.trim() : undefined; in addressOf. Then located stays as it was for Markdown images.

PageImage.url should probably keep the untrimmed value, since the error message quotes it as written. If so, pass the trimmed value through separately rather than trimming in addressOf.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed. Only an <img>'s src is trimmed now, in addressOf, and located reads a Markdown image's address as remarkImage does; a test covers ![b](< ./b.png >). I kept url trimmed for an <img>, since the message gives the line and spaces inside quotes are hard to see.

Comment thread src/types/portal/page.ts Outdated
return node.url;
case 'imageReference':
return definitions.get(node.identifier ?? '');
// An `<img>` in an `.mdx` page, which the template imports too (remark-jsx-images.ts).

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.

Nit: the case labels already say the first half of this comment. The pointer to remark-jsx-images.ts is the part worth keeping, e.g. // remark-jsx-images.ts imports these too.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Cut to the pointer.

Comment thread portal-template/remark-jsx-images.ts Outdated

function fileOf(address: string, publicDir: string | false, dirname: string): string | null {
// A browser drops the spaces around an address.
const written = address.trim().split(/[?#]/, 1)[0];

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.

This is the template's copy of the rule in page.ts (located/decodedAddress). The parity test keeps the two copies agreeing on behaviour. Making them read the same would also let a plain diff show any drift. Right now they differ in small ways:

  • the names: written here, address there;
  • the comment wording for the trim;
  • located strips leading /s with a regex, while this copy relies on path.join;
  • page.ts reads src before it checks name === 'img', and imageSources checks the name first.

None of these is a bug.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Aligned the names (url, address, decoded), and both copies now trim where src is read, under the same comment. Two differences stay: the CLI needs the path inside static/ while the plugin joins it to the directory, and one copy is a switch case while the other is a generator.

Comment thread portal-template/remark-jsx-images.ts Outdated
return (tree: MarkdownNode, { dirname }: { dirname?: string }) => {
if (dirname === undefined) return;
const imports: MarkdownNode[] = [];
for (const [src, address] of imageSources(tree)) {

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.

This loop still does three things: works out the file and its relative path, builds the mdxjsEsm import node, and rewrites src. Pulling out something like importOf(name, source), beside a small expressionOf(name), would finish the separation that dd2e08c started. The loop would then read as: resolve, import, replace.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done, with importOf and expressionOf.

Comment thread portal-template/remark-jsx-images.ts Outdated
// A scheme, a drive letter or a network path: none of them names a file the build imports.
const NOT_A_FILE = /^(?:[a-z][a-z\d+.-]*:|\/\/)/i;

const program = (statement: object) => ({ type: 'Program', sourceType: 'module', body: [statement] });

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.

Nit: program could say a little more about what it builds, e.g. estreeProgram(statement) or moduleOf(statement).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Renamed to estreeProgram.

Comment thread portal-template/remark-jsx-images.ts Outdated
};
}

function* imageSources(node: MarkdownNode): Generator<[JsxAttribute, string]> {

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.

Nit: the string in the tuple is always src.value already narrowed. You could yield only the attribute and read src.value as string at the call site. Or keep the tuple and name it, e.g. { attribute, address }.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

It yields { attribute, address } now.

});

// The CLI reports a missing file before a build that would otherwise fail over its import, naming no page.
it('imports exactly the files the CLI checks', async () => {

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.

This is the test that would catch the two copies drifting, but its input avoids the edge cases where they're most likely to drift. Consider adding:

  • an address decodeURI rejects (./a%E0.png);
  • one ending in /;
  • data: and // addresses;
  • a run with publicDir: false.

Reusing the inputs of the "leaves an address that names no file" test would do most of it.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Added ./h%E0.png, the "names no file" list (now shared by both tests), and a run with publicDir: false, where only the files beside the page are imported.

);
});

(process.platform === 'win32' ? it : it.skip)(

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.

Nit: the other Windows-only tests skip with if (process.platform !== 'win32') this.skip();, for example test/types/file/directory-path.test.ts:35, test/actions/portal/prepare-project.test.ts:21 and test/portal-template/static-images.test.ts:98. Using that form here would match them.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Switched to this.skip().

Comment thread portal-template/vite.config.ts Outdated
import { prerenderPages } from './prerender-pages.ts';
import { specReload } from './spec-reload.ts';
import { remarkImageReferences } from './src/lib/remark-image-references.ts';
import { remarkJsxImages } from './remark-jsx-images.ts';

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.

Nit: the local imports are otherwise in alphabetical order, so this one belongs after ./prerender-pages.ts.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Moved.

…kdown image's spaces

The CLI trimmed a Markdown image's address too, so it accepted
`![x](< ./flow.png >)` while the build looked for `./ ./flow.png ` and
failed. Both copies of the rule now trim where an <img>'s src is read.

Also from the review: the plugin's import and address nodes move into
importOf and expressionOf, the copies share their names, the contract
test covers the addresses the plugin leaves alone, one decodeURI
refuses and a portal without a static directory, and the root-hosted
fixture bundles an <img> beside its page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

@saeedjamshaid

Copy link
Copy Markdown
Collaborator Author

Thanks for testing it this thoroughly. On the points outside the code:

  1. The description now states the <include> change as one for the release notes. Checking included files is left for a follow-up, since it applies to Markdown images too.
  2. Added to "Not in this PR".
  3. The fixture hosted at the root now has an <img> naming a file beside its page, and the build test checks that it's bundled. The /api build already covers both kinds.
  4. The PR is squash-merged, so the split doesn't reach dev.

@saeedjamshaid
saeedjamshaid merged commit 763b05c into dev Oct 27, 2026
11 of 12 checks passed
saeedjamshaid added a commit that referenced this pull request Oct 27, 2026
Brings in #456 as merged, and #448, which removed reportSharedEndpoints
and its tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@saeedjamshaid
saeedjamshaid deleted the saeedjamshaid/mdx-raw-images branch October 27, 2026 12:00
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.

2 participants