Skip to content

fix(portal): keep the portal's styling when it is built from another drive - #440

Merged
saeedjamshaid merged 12 commits into
devfrom
saeedjamshaid/cross-drive-portal-styles
Oct 19, 2026
Merged

saeedjamshaid merged 12 commits into
devfrom
saeedjamshaid/cross-drive-portal-styles

Conversation

@saeedjamshaid

@saeedjamshaid saeedjamshaid commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

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% on C:) 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 .gitignore of *, 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 linked node_modules included: 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

  • Every portal project is built in the system temp directory, on every platform and drive. Nothing is written into the input folder any more, so a preview stopped abruptly leaves nothing behind there.
  • A page's static images work across drives. The fallback only existed to dodge two upstream Windows bugs, which a new template plugin, portal-template/static-images.ts, now handles:
    • Fumadocs' remarkImage imports a static image by path.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.
    • Vite's dev server serves no raw file from another drive (vitejs/vite issue 10802). Under portal serve, a page's static image now loads from the address the public directory serves it at, /images/x.png, under the base when site.url has 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.css names the folders Tailwind scans: the template's src/, the copy of the pages, the generated pages and the SDK docs they include. Left to detect them, Tailwind still took in node_modules whenever the temp directory sat inside a git repository that ignores it, as some CI and dev-container setups have it.
  • A source on a network share (\\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

  • On macOS, Linux and same-drive Windows, portal generate writes the same portal as before, less one unused rule (.collapse) that a word in a template comment had produced.
  • Under 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 generate and portal serve stop 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.
  • For the release notes: the CLI no longer writes .apimatic-build/ folders beside src/. Ones left by earlier versions are ignored by git and can be deleted.

Tests

  • Unit:
    • test/portal-template/static-images.test.ts covers:
      • which imports the resolve hook takes;
      • the address at the root and under /docs/;
      • both spellings of the static directory, and on Windows any case;
      • what is left to Vite.
    • DirectoryPath.isOnNetworkShare, and the preparation both commands run stopping on a share before signing in (Windows only).
    • The fallback's own tests are gone, and the temp directory's clean-up test now covers withDirPath.
    • A packaging test checks that app.css names the folders the CLI writes pages into.
  • End to end:
    • The stylesheet holds a class only the template uses (@2xl:grid-cols-2, the SDK cards' grid) and a class only a page uses (tracking-[0.3125em], in the default fixture's authentication.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 scans node_modules.
    • The serve test asks for a static image's module before the page, then fetches the address the module names.
    • The e2e roots are made in the temp directory under its real name, as portal generate names its project. On the Windows runner (workspace on D:, temp on C:), both suites now run across drives for real.

Verification

  • Windows: CI's job gives 1924 passing and a clean lint. The e2e suites give 69/69 from a subst drive, and gave 68/68 from a USB drive before the review's changes.
  • Linux: CI's job in Docker, on Node 24 and on Node 26, gave 1918 passing each before the review's changes.
  • From a second drive:
    • portal generate gives a stylesheet byte-identical to the same input built from C:, with and without a git repository.
    • portal serve serves the image, the logo and the full stylesheet, under a sub-path too.
    • Headless Chrome loaded the SDK and image pages with no failed request when the project and the CLI's dependencies sat on different drives.
  • Tailwind's sources: the default fixture's stylesheet is the same 93,306 bytes and 736 classes with the temp directory in its usual place and inside an ignored folder of a git repository. With detection, the second gave 133,997 bytes and 562 more classes.
  • Network shares: both commands stop on \\localhost\C$\… with the message. Mapped to a drive letter, the same share builds completely, static directory included, and portal serve through it loads the images, the logo and the full stylesheet.
  • Not run: Safari on macOS. CI's job covers macOS. The Safari check of a portal serve page with a static image is deferred to QA's pass of the dev build after merge (steps on the tracking issue).

🤖 Generated with Claude Code

saeedjamshaid and others added 5 commits October 16, 2026 18:11
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 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.

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 with dev (#439): merges cleanly, 1920 passing.
  • portal generate, input on D: outside git, inside git, from a subst drive, 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. On dev the same inputs gave 91.8 KB / 790 classes outside git and 134 KB / 1380 classes inside git.
  • portal serve in headless Chrome, cross-drive, under /docs, same drive, subst, and with generate running 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 serve a 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 beside src/ by earlier CLI versions are never removed. Git ignores them, so a release note is enough.
  • SonarCloud reports one new issue on this PR.

Comment thread test/e2e/portal-build.test.ts Outdated
});

// 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', () => {

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

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.

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

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 //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.

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.

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 generate and portal serve now 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.

Comment thread portal-template/static-images.ts Outdated
enforce: 'pre',
resolveId: {
// Fumadocs' import of a file on another drive: the absolute path, still behind a `./`.
filter: { id: /^\.\/(?:[A-Za-z]:\/|\/\/)/ },

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: // 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.

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.

Gone with the // branch (586f226). .//local.png is now in the "leaves every other import to Vite" list, so it stays that way.

Comment thread portal-template/static-images.ts Outdated
},
// 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));

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: 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.

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: 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.

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.

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.

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.

Recorded in the plan's §10, with the 403 from a subst drive (5a67acf).

Comment thread portal-template/static-images.ts Outdated
name: 'apimatic:static-images:resolve',
enforce: 'pre',
resolveId: {
// Fumadocs' import of a file on another drive: the absolute path, still behind a `./`.

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 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?

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.

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

Comment thread portal-template/src/styles/app.css Outdated
@@ -19,10 +19,6 @@

@source not '../../dist';

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.

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.

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.

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.

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 out of date now that the branch is pushed ("Not yet pushed; steps 5 and 6 wait for the user").

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.

Updated in 5a67acf.

## 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

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

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.

Corrected in 5a67acf. §10 now records both failures and the mapped-drive check, and a new D7 records the refusal.

saeedjamshaid and others added 5 commits October 19, 2026 15:12
…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>
@saeedjamshaid

Copy link
Copy Markdown
Collaborator Author

Thanks for testing this so thoroughly. The inline threads are answered; on the rest:

  • Behind dev: I merged dev in rather than rebasing, so "changes since your last review" still works. It merged cleanly.
  • Serve and build addresses: added to the description and the plan's §10.
  • Leftover .apimatic-build/ folders: noted in the description, for the release notes.
  • SonarCloud: the nested template literal is gone (02e4857).
  • Safari: still open.

@saeedjamshaid

Copy link
Copy Markdown
Collaborator Author

Safari check: deferred to QA's pass of the dev build once this is merged, since no one on the PR has a Mac. The steps are on the tracking issue: https://github.com/apimatic/apimatic-io/issues/2301#issuecomment-6265399206

saeedjamshaid and others added 2 commits October 19, 2026 15:47
…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>
@sonarqubecloud

Copy link
Copy Markdown

@saeedjamshaid
saeedjamshaid merged commit 4baa948 into dev Oct 19, 2026
10 of 12 checks passed
saeedjamshaid added a commit that referenced this pull request Oct 20, 2026
…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>
waseem196 added a commit that referenced this pull request Oct 20, 2026
…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>
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