Repository navigation
fix(portal): keep the portal's styling when it is built from another drive - #440
Conversation
Fumadocs imports a static image by its path relative to the page. Across Windows drives that comes out as the absolute path behind a `./` (`./D:/site/src/static/x.png`), and Vite's dev server serves no raw file from another drive at all (vitejs/vite#10802). The new `static-images` plugin resolves that import as the absolute path it is. Under `portal serve` it also loads a plain image import from the static directory as the address the public directory serves it at, under the base. Nothing else changes yet: the cross-drive fallback still keeps the static directory on the project's drive. Also adds the plan for apimatic-io#2301, `.ai/plans/cross-drive-portal-projects.md`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On Windows, an input on another drive than the temp directory had its portal project staged in `<input>/.apimatic-build/`, under a `.gitignore` of `*`. Tailwind's scanner then read none of the template's files outside a git repository, so the portal lost every utility only the template uses, and inside one it scanned the linked `node_modules` as well. The project now lives in the system temp directory on every platform and drive. The images the fallback kept reachable are served by the `static-images` plugin, and nothing is written into the input folder any more. The e2e helpers stage their roots the same way, with the temp directory's real name, and the serve test asks for a static image's module before the page and then fetches the address it names. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`app.css` named the copy of the pages to Tailwind because the project could sit in a git-ignored folder beside the source. It no longer can, and the copy is inside the project, so Tailwind's own scan reads it with the rest of the template. The line goes, with the unit test that held its text. An end-to-end case holds the contract that line stood for: a class only a page uses, in the default fixture's `authentication.mdx`, has its rule in the built stylesheet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… at its replacement Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
mehnoorsiddiqui
left a comment
There was a problem hiding this comment.
Tested this on Windows with the repo on a real second drive (D:) and %TEMP% on C:, the same layout as the GitHub runner, using dev as the baseline. The fix works: the bug reproduces on dev and is gone here.
What passed
- Full suite with
APIMATIC_E2E=1, cross-drive: 1919 passing, ESLint clean. Merged withdev(#439): merges cleanly, 1920 passing. portal generate, input on D: outside git, inside git, from asubstdrive, and through an odd-cased path (d:\PR440\Inputs): the stylesheet is byte-identical to a same-drive C: build (93.3 KB, 816 classes), with the SDK grid and page classes present. Ondevthe same inputs gave 91.8 KB / 790 classes outside git and 134 KB / 1380 classes inside git.portal servein headless Chrome, cross-drive, under/docs, same drive,subst, and withgeneraterunning at the same time: no failed requests, images load from/images/…, the page class applies, the SDK grid shows 2 columns, editing an image reloads the page, and a killed server leaves nothing in the input folder.
Before merge
- The Safari check in the plan (§8 step 5) is still open, and this change moves serve images from
/@fs/…to/images/…. - The branch is one commit behind
dev; worth rebasing. - The inline comments below cover the test that doesn't catch this regression on CI, network shares, and Tailwind's scan when
%TEMP%is inside a git repo.
Minor
- Under
servea static image loads from/images/x.png; a build bundles it to/assets/x-<hash>.png. Fine for a preview, but worth a line in the description. .apimatic-build/folders left besidesrc/by earlier CLI versions are never removed. Git ignores them, so a release note is enough.- SonarCloud reports one new issue on this PR.
| }); | ||
|
|
||
| // Tailwind reads the copy of the pages with the rest of the project, so a class a page uses has its rule. | ||
| it('generates the utility classes a page uses', () => { |
There was a problem hiding this comment.
This passes on dev in the layout CI uses, so it doesn't guard the bug it was added for. The Windows runner builds inside a git checkout, and there dev over-scans instead of dropping classes. I built the default fixture from D: inside a git repo with dev: 134 KB of CSS, 1380 class selectors, tracking-[0.3125em] present. Outside git, dev gives 790 classes and this test fails, but CI never runs that case.
Could this also assert a class only the template uses (e.g. @2xl:grid-cols-3 from sdk-cards.tsx) and put a ceiling on stylesheet size or stray classes? With this PR every layout I tried gives 93.3 KB / 816 classes, so a ceiling around 100 KB would catch both failure modes.
There was a problem hiding this comment.
Right: the Windows runner builds inside its checkout, where dev over-scans instead of dropping classes, so the page class survives either way. The case is now "builds the stylesheet from the template and the pages alone". It also asserts @2xl:grid-cols-2, the SDK cards' grid, which only the template uses and which the reported build lost. (@2xl:grid-cols-3 is in plugin-support.tsx, which the default fixture doesn't render.) It also keeps the stylesheet under 100 KB: 93.3 KB now, 134 KB when the scan takes in node_modules. This replaces the branded build's dark:block check, so one case covers the scan. Fixed in 6cd6664.
| // Fumadocs' import of a file on another drive: the absolute path, still behind a `./`. | ||
| filter: { id: /^\.\/(?:[A-Za-z]:\/|\/\/)/ }, | ||
| handler(source, importer, options) { | ||
| return this.resolve(source.slice(2), importer, { ...options, skipSelf: true }); |
There was a problem hiding this comment.
The //server/share branch doesn't work. Once ./ is stripped, this.resolve('//localhost/D$/…') is read as a protocol-relative URL, and the SSR bundle keeps it as an import that Node can't load. With the input at \\localhost\D$\pr440\inputs\nogit and a page using a static image, the build fails during prerender:
TypeError [ERR_INVALID_FILE_URL_PATH]: File URL path must be absolute
input: 'file:///D$/pr440/inputs/nogit/src/static/images/diagram.png'
Error: Failed to fetch /guides/intro.md: Internal Server Error
Returning the id directly made that build pass, with the images bundled:
const absolute = source.slice(2);
if (absolute.startsWith('//')) return absolute;
return this.resolve(absolute, importer, { ...options, skipSelf: true });Even then, a share input loses its static directory: /images/logo.png is missing from the output, and nothing reports it. I got that from a share input with no page images too. dev fails earlier on a share (EPERM creating a link), so this isn't a regression. Either fix both, or fail early with a clear "network shares aren't supported" message and drop the // branch from the filter.
There was a problem hiding this comment.
Reproduced both on \\localhost\C$. The second one is Vite's: normalizePath turns \\localhost\C$\… into /localhost/C$/…, so publicDir names no folder and the copy is skipped without a word. I went with failing early.
portal generateandportal servenow stop on a share before signing in. The message says to copy the folder to a local drive or map the share to a drive letter.- A share mapped to
Z:builds completely, static directory included. - The
//branch is gone from the filter.
Fixed in 586f226.
| enforce: 'pre', | ||
| resolveId: { | ||
| // Fumadocs' import of a file on another drive: the absolute path, still behind a `./`. | ||
| filter: { id: /^\.\/(?:[A-Za-z]:\/|\/\/)/ }, |
There was a problem hiding this comment.
Nit: // also matches .//foo.png, a valid relative path that this would turn into //foo.png. Fumadocs is unlikely to emit it, but if the UNC branch stays, ^\.\/\/\/[^/] (.///server…) would be tighter.
There was a problem hiding this comment.
Gone with the // branch (586f226). .//local.png is now in the "leaves every other import to Vite" list, so it stays that way.
| }, | ||
| // Vite's dev server serves no file from another drive (vitejs/vite#10802), but its public directory from any. | ||
| load(id) { | ||
| const directory = directories.find((dir) => id.startsWith(dir)); |
There was a problem hiding this comment.
Nit: startsWith is case-sensitive, and Windows paths aren't. Matching the realpathSync.native spelling covered every case I tried (d:\PR440\Inputs\… and a subst drive both worked), but lowercasing both sides on win32 would be safer.
There was a problem hiding this comment.
Done: on Windows both sides are lower-cased before the comparison. A Windows-only test checks that the directory spelled in upper case still matches. Fixed in 02e4857.
| // Vite's dev server serves no file from another drive (vitejs/vite#10802), but its public directory from any. | ||
| load(id) { | ||
| const directory = directories.find((dir) => id.startsWith(dir)); | ||
| // A query such as `?raw` asks for more than the address, and stays Vite's to answer. |
There was a problem hiding this comment.
Worth recording as a known limitation: across drives, a ?url import of a static image comes back from Vite as export default "/@fs/D:/…/diagram.png", and fetching that address returns the HTML fallback page. From a subst drive the same ?url request gets a 403. Under the old fallback the project was on the same drive, so this worked. Content pages probably never do this, so it doesn't block, but the plan should mention it.
There was a problem hiding this comment.
Recorded in the plan's §10, with the 403 from a subst drive (5a67acf).
| name: 'apimatic:static-images:resolve', | ||
| enforce: 'pre', | ||
| resolveId: { | ||
| // Fumadocs' import of a file on another drive: the absolute path, still behind a `./`. |
There was a problem hiding this comment.
The plan says this fumadocs remarkImage bug is "not reported upstream". Could you file it and link the issue here or in plan §11, so this hook can go once a fixed version is pinned?
There was a problem hiding this comment.
Filed as fuma-nama/fumadocs#3723. The code is unchanged in 16.17.6 and on main. It's linked from this comment (586f226) and from the plan's §11 (5a67acf).
| @@ -19,10 +19,6 @@ | |||
|
|
|||
| @source not '../../dist'; | |||
There was a problem hiding this comment.
Without the @source line, which files Tailwind scans depends on where %TEMP% lives. I set TEMP/TMP to a folder inside a git repo whose .gitignore ignores it (.tmp/), as some CI and devcontainer setups do. The result is the in-git over-scan again: 134 KB, 1380 classes. dev does the same, so it isn't a regression.
I tried @import 'tailwindcss' source(none); with @source '../', @source '../../content' and @source '../../generated'. It gave 93.3 KB / 815 classes with the temp folder ignored, not ignored, and in the normal place. The only difference from this PR's output is collapse, which needs checking. Plan D5 rejected explicit sources because of the * .gitignore, and that file is gone now, so it may be worth another look. At minimum, list this case in §10.
There was a problem hiding this comment.
Adopted. collapse turned out to be a stray: the word is in a comment in the template's root prerender-pages.ts, which the named sources no longer read. I measured the default fixture in two layouts, with the temp folder where it usually is and inside an ignored folder of a git repository:
- named sources: 93,306 B and 736 classes in both;
- detection: 93,336 B / 737 in the usual place, and 133,997 B / 1,298 inside the ignored folder.
I added generated-includes/ (the SDK docs the generated pages include) to your three, so the named sources cover every folder detection read except the template's root files, which hold build code. A packaging test pins the folder names to the CLI's constants. @source not '../../dist' went too, since nothing named holds dist/. D4 and D5 in the plan are revised. Fixed in 6cd6664.
| 2026-10-16, with the title in section 12. | ||
|
|
||
| Implemented the same day in steps 1–4: `c1c04efe`, `341d2f30`, `b198b89c`, `494ffaa5`, plus this | ||
| note. Not yet pushed; steps 5 and 6 wait for the user. Section 13 records what the branch passed. |
There was a problem hiding this comment.
This is out of date now that the branch is pushed ("Not yet pushed; steps 5 and 6 wait for the user").
| ## 10. Known limitations | ||
|
|
||
| - **A source on a network share.** | ||
| - The resolve hook takes fumadocs' UNC form (`.///server/share/…`), and a share is served as the |
There was a problem hiding this comment.
This isn't what I see on a share (\\localhost\D$\…). The resolve hook's // branch fails, and the static directory isn't copied into a build. Details are on static-images.ts line 14. Please correct this section, whichever way that comment is resolved.
There was a problem hiding this comment.
Corrected in 5a67acf. §10 now records both failures and the mapped-drive check, and a new D7 records the refusal.
…ive-portal-styles Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Vite's `normalizePath` turns `\\server\share` into `/server/share`, so a share's static directory named no folder and was left out of the build without a word. Fumadocs' `.///server/share/...` image import, once the plugin took its `./` off, was a protocol-relative URL that prerendering could not load. `portal generate` and `portal serve` now stop on a share before signing in, and say to copy the folder to a local drive or map the share to a drive letter, which builds like any other drive. The static-images plugin no longer takes the `//` form, and its filter links the Fumadocs issue. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The preview's static-image hook compared paths case-sensitively, though a Windows path names the same file in any case. It now lower-cases both sides there. The address is built before it is written into the module, which also clears SonarCloud's nested template literal. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Detection still read the linked node_modules whenever the temp directory sat inside a git repository that ignores it: 562 more classes and a 134 KB stylesheet. `app.css` now names the template's `src/`, the copy of the pages, the generated pages and the SDK docs they include. That gives the same stylesheet in both layouts, less a `collapse` rule that a template comment produced. The e2e case also asserts a class only the template uses and a 100 KB ceiling. CI's Windows runner builds inside its checkout, where a scan of node_modules keeps every class, so only the ceiling catches it there. A packaging test pins the named folders to the CLI's constants. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks for testing this so thoroughly. The inline threads are answered; on the rest:
|
|
Safari check: deferred to QA's pass of the |
…on's own test The same case in the generate and the serve tests joined the cases around it into one 34-line duplicate, which failed SonarCloud's gate. The check lives in `PreparePortalProjectAction`, which both commands run, so its test does too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
…ec-errors Brings in #439 (Fumadocs components on content pages), #427 (dead code and isomorphic-git removed) and #440 (a portal built from another drive keeps its styling). - src/actions/action-result.ts: dev's version, without the methods #427 removed, formatted as the commit hook formats it. - test/e2e/portal-serve.test.ts: #440 removed the helpers that put a project on the fixture's drive, so each project is now built in the temp directory, as #440 does. The main preview serves the checkout's fixture, as #440's test does, which keeps a CI runner's cross-drive case. The tests that edit the specification each preview a copy of the fixture reached through a link. #440's static image test runs against both, and a test of a static file asked for by its real path through the link keeps the allow-list fix of this branch covered. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s classes either #440 built the project where Tailwind no longer picks up classes nothing uses, so the default portal's stylesheet can be held to having none of the chat's, as its code already was. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>



The problem
On Windows, a portal built or previewed from an input on another drive than the temp directory (say
D:\work\my-api, with%TEMP%onC:) came out without the template's own styles. The SDK cards stacked one per row, the copy button covered the command it copies, the navbar logo showed at its natural size and the navbar labels wrapped. A utility class written in a page was dropped too.The CLI staged such a build in
<input>\.apimatic-build\, hidden from git by a.gitignoreof*, and Tailwind's scanner respects that file. Outside a git repository it read none of the template's files. Inside one it took the folder for an explicitly ignored source and scanned all of it, the linkednode_modulesincluded: 561 stray utilities, and a stylesheet of 134 KB instead of 93 KB. GitHub's Windows runners build this repository's own e2e portals that way.Tracked in apimatic-io issue 2301.
What changes
portal-template/static-images.ts, now handles:remarkImageimports a static image bypath.relative, which across drives gives./D:/site/src/static/x.png(reported as remarkImage writes an invalid import path for an image on another Windows drive fuma-nama/fumadocs#3723). The plugin resolves that as the absolute path it is; until now a build only worked by accident.portal serve, a page's static image now loads from the address the public directory serves it at,/images/x.png, under the base whensite.urlhas a path. That holds on every platform, so the macOS and Linux serve tests cover it. Queries such as?raw, and imports that are not assets, stay Vite's to answer.app.cssnames the folders Tailwind scans: the template'ssrc/, the copy of the pages, the generated pages and the SDK docs they include. Left to detect them, Tailwind still took innode_moduleswhenever the temp directory sat inside a git repository that ignores it, as some CI and dev-container setups have it.\\server\share\…) is refused up front. Vite loses a slash from such a path, so the static directory was left out of the build without a word.What users see change
portal generatewrites the same portal as before, less one unused rule (.collapse) that a word in a template comment had produced.portal serve, static images load from/images/...instead of/@fs/.... Editing one still reloads the page. A built portal bundles them under/assets/as before.portal generateandportal servestop on a network share before signing in, and say to copy the folder to a local drive or map the share to a drive letter. They failed there before too, with a less clear error..apimatic-build/folders besidesrc/. Ones left by earlier versions are ignored by git and can be deleted.Tests
test/portal-template/static-images.test.tscovers:/docs/;DirectoryPath.isOnNetworkShare, and the preparation both commands run stopping on a share before signing in (Windows only).withDirPath.app.cssnames the folders the CLI writes pages into.@2xl:grid-cols-2, the SDK cards' grid) and a class only a page uses (tracking-[0.3125em], in the default fixture'sauthentication.mdx). It also stays under 100 KB. The first assertion fails when Tailwind skips the template, the second when it skips the pages, and the third when it scansnode_modules.portal generatenames its project. On the Windows runner (workspace on D:, temp on C:), both suites now run across drives for real.Verification
substdrive, and gave 68/68 from a USB drive before the review's changes.portal generategives a stylesheet byte-identical to the same input built from C:, with and without a git repository.portal serveserves the image, the logo and the full stylesheet, under a sub-path too.\\localhost\C$\…with the message. Mapped to a drive letter, the same share builds completely, static directory included, andportal servethrough it loads the images, the logo and the full stylesheet.portal servepage with a static image is deferred to QA's pass of thedevbuild after merge (steps on the tracking issue).🤖 Generated with Claude Code