Skip to content
Merged
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
357 changes: 357 additions & 0 deletions .ai/plans/cross-drive-portal-projects.md

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions .ai/plans/fumadocs-portal.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,8 @@ Tailwind 4 is mandatory for Fumadocs UI.
directory and `path.relative` cannot express one across drives, so the content
pages silently vanished (found on GitHub's Windows runners: workspace on D:,
temp on C:; reproduced locally with `subst`).
Superseded 2026-10-16: the fallback is gone, and every project is built in the system temp
directory (`cross-drive-portal-projects.md`).
3. Copy `portal-template/` into it.
4. Create a real `tempDir/node_modules/` directory and add one junction
(Windows) or symlink (elsewhere) per template dependency, each resolved to
Expand Down Expand Up @@ -418,6 +420,9 @@ the new-layout branch in the sample repository.
fix belongs upstream: emit an absolute path (or `/@fs/`) when `path.relative`
returns one. File an issue with fumadocs-mdx; remove the fallback once a fixed
version is pinned.
Resolved 2026-10-16 another way: the content has been copied into the project since #375, and
the template's `static-images` plugin handles the one import still left across drives, a static
image's (`cross-drive-portal-projects.md`).
- **Sidebar tree dominates at scale.** Whole-spec prerenders measured 2026-09-17:
Stripe (594 operations) 2.2 GB in 375 s, GitHub (1,511 pages) 2.4 GB in 229 s.
A GitHub page is 1,971 KB: 1,453 KB of inlined router state (almost all the
Expand Down
4 changes: 4 additions & 0 deletions .ai/plans/generated-pages.md
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,9 @@ scans `generated/` as it scans `src/`, which step 1 confirmed in a plain
directory and in one whose `.gitignore` is `*`, as the cross-drive
`.apimatic-build/` fallback's is (section 16). `app.css` names the content
directory with `@source` only because that directory lies outside the project.
Corrected 2026-10-16: the `*` case was measured from outside the project. With Vite's working
directory inside it, Oxide scans nothing under that rule outside a git repository, and everything,
`node_modules` included, inside one (apimatic-io#2301, `cross-drive-portal-projects.md` section 2).

### Adding and removing pages under `portal serve`

Expand Down Expand Up @@ -881,6 +884,7 @@ the observations are in section 9. What they changed in the plan:
`.apimatic-build/` fallback is, so the template gains no line in `app.css`.
The end-to-end build on the cross-drive CI runner is where the fallback
itself is exercised.
Corrected 2026-10-16: see the correction under "Tailwind" above; the fallback itself is gone.
3. **The rest held as planned.** The relative `dir` literal publishes only the
word `generated`, no path of the build machine reaches the output, the
`.md` twins carry the page bodies through the collection split, a generated
Expand Down
10 changes: 5 additions & 5 deletions portal-template/src/styles/app.css
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
@import 'tailwindcss';
@import 'tailwindcss' source(none);
@import 'fumadocs-ui/css/neutral.css';
@import 'fumadocs-ui/css/preset.css';
@import 'fumadocs-openapi/css/preset.css';
Expand All @@ -17,11 +17,11 @@
'Courier New', monospace;
}

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

/* Named, not detected: the project can sit in a git-ignored folder beside the source, which
Tailwind's detection skips, and a utility class in a page would produce no CSS. */
/* Named, not detected: detection reads node_modules too wherever git ignores the temp directory. */
@source '../';
@source '../../content';
@source '../../generated';
@source '../../generated-includes';

html {
scrollbar-gutter: stable;
Expand Down
46 changes: 46 additions & 0 deletions portal-template/static-images.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { realpathSync } from 'node:fs';
import { normalizePath, type Plugin, type ResolvedConfig } from 'vite';
import { withBasePath } from './src/lib/base-path';

/** A page's images from the static directory, which can sit on another Windows drive than this project. */
export function staticImages(staticDirectory: string | null): Plugin[] {
const resolve: Plugin = {
name: 'apimatic:static-images:resolve',
enforce: 'pre',
resolveId: {
// Fumadocs' import of a file on another drive: the absolute path, still behind a `./` (fuma-nama/fumadocs#3723).
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.

}
}
};
if (staticDirectory === null) return [resolve];

// A Windows path names the same file in any case.
const folded = (path: string) => (process.platform === 'win32' ? path.toLowerCase() : path);
// Vite names a module by its real path, but keeps the spelling of a `subst` drive.
const directories = [staticDirectory, realpathSync.native(staticDirectory)].map((dir) =>
folded(`${normalizePath(dir)}/`)
);
let config: ResolvedConfig;
return [
resolve,
{
name: 'apimatic:static-images:serve',
apply: 'serve',
enforce: 'pre',
configResolved(resolved) {
config = resolved;
},
// 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) => folded(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).

if (directory === undefined || id.includes('?') || !config.assetsInclude(id)) return null;
const address = withBasePath(`/${id.slice(directory.length)}`, config.base);
return `export default ${JSON.stringify(address)};`;
}
}
];
}
2 changes: 2 additions & 0 deletions portal-template/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { prerenderPages } from './prerender-pages.ts';
import { specReload } from './spec-reload.ts';
import { remarkImageReferences } from './src/lib/remark-image-references.ts';
import { staticFunctionsBase } from './static-functions-base.ts';
import { staticImages } from './static-images.ts';

export default defineConfig(async () => {
const [paths, identity] = await Promise.all([readBuildPaths(), readPortalIdentity()]);
Expand All @@ -27,6 +28,7 @@ export default defineConfig(async () => {
generatedPagesReload(),
downloads(paths.downloadsDir),
staticFunctionsBase(),
staticImages(paths.staticDir),
// `/images/logo.png` is read from the static directory, and a remote image is never fetched for its size.
fumadocsMdx({
globalOptions: {
Expand Down
10 changes: 8 additions & 2 deletions src/actions/portal/prepare-project.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { PortalProjectPaths, PortalProjectService } from '../../infrastructure/portal-project-service.js';
import { PortalArtifactsService } from '../../infrastructure/services/portal-artifacts-service.js';
import { PortalAuthorizationService } from '../../infrastructure/services/portal-authorization-service.js';
import { withPortalProjectDirectory, withDirPath } from '../../infrastructure/tmp-extensions.js';
import { withDirPath } from '../../infrastructure/tmp-extensions.js';
import { PreparePortalProjectPrompts } from '../../prompts/portal/prepare-project.js';
import { CommandMetadata } from '../../types/common/command-metadata.js';
import { DirectoryPath } from '../../types/file/directoryPath.js';
Expand Down Expand Up @@ -46,6 +46,12 @@ export class PreparePortalProjectAction {
return ActionResult.failed();
}

// Vite drops a slash from a share's `\\server`, so the portal would be built without its static files.
if (sourceDirectory.isOnNetworkShare()) {
this.prompts.networkShareUnsupported(sourceDirectory);
return ActionResult.failed();
}

const authorization = await this.authorizationService.authorize(
this.configDir,
this.commandMetadata.shell,
Expand Down Expand Up @@ -98,7 +104,7 @@ export class PreparePortalProjectAction {
return ActionResult.failed();
}

return await withPortalProjectDirectory(sourceDirectory, async (tempDirectory) => {
return await withDirPath(async (tempDirectory) => {
const portalProject = await this.projectService.prepare(tempDirectory, source.value, artifacts.value);
if (portalProject.isErr()) {
this.prompts.runtimeUnsupported(portalProject.error);
Expand Down
83 changes: 0 additions & 83 deletions src/infrastructure/tmp-extensions.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@
import { realpathSync } from 'node:fs';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { DirectoryPath } from '../types/file/directoryPath.js';
import { withDir } from 'tmp-promise';

Expand All @@ -22,83 +19,3 @@ export function canonical(directory: string): string {
export function withDirPath<T>(fn: (results: DirectoryPath) => Promise<T>): Promise<T> {
return withDir((results) => fn(new DirectoryPath(canonical(results.path))), { unsafeCleanup: true });
}

/** Name of the folder created beside a project when the system temp directory is unusable. */
export const PORTAL_PROJECT_DIRECTORY_NAME = '.apimatic-build';

/**
* Where a portal project may live for a source directory. The system temp directory,
* unless it sits on a different Windows drive from the source: a page imports an image from
* the static directory by a path relative to the page, and `path.relative` cannot express one
* across drives. GitHub's Windows runners (workspace on D:, temp on C:) are the common case.
*/
export function portalProjectDirectoryBase(
sourceDirectory: string,
systemTemp: string = os.tmpdir(),
platform: NodeJS.Platform = process.platform
): string {
if (platform !== 'win32') return systemTemp;
const source = path.win32.resolve(sourceDirectory);
if (
path.win32.parse(source).root.toLowerCase() === path.win32.parse(path.win32.resolve(systemTemp)).root.toLowerCase()
) {
return systemTemp;
}
return path.win32.join(path.win32.dirname(source), PORTAL_PROJECT_DIRECTORY_NAME);
}

/** Creates the base directory when it is the project-side fallback, hidden from git. */
export async function ensurePortalProjectDirectoryBase(
sourceDirectory: DirectoryPath,
systemTemp: string = os.tmpdir()
): Promise<string> {
const base = portalProjectDirectoryBase(sourceDirectory.toString(), systemTemp);
if (base !== systemTemp) {
await fs.mkdir(base, { recursive: true });
// A gitignore that ignores everything keeps the folder out of the user's status while it exists.
await fs.writeFile(path.join(base, '.gitignore'), '*\n');
}
return base;
}

/**
* Like `withDirPath`, for a portal project that must read files under `sourceDirectory`.
* The project-side fallback folder is removed again once it is empty.
*/
export async function withPortalProjectDirectory<T>(
sourceDirectory: DirectoryPath,
fn: (directory: DirectoryPath) => Promise<T>,
systemTemp: string = os.tmpdir()
): Promise<T> {
const base = await ensurePortalProjectDirectoryBase(sourceDirectory, systemTemp);
try {
return await withDir((results) => fn(new DirectoryPath(canonical(results.path))), {
tmpdir: base,
unsafeCleanup: true
});
} finally {
await removePortalProjectDirectoryBase(base, systemTemp);
}
}

/**
* The project-side fallback folder is shared by every invocation for that project, so it is
* only taken down once nothing else is in it -- otherwise a `portal generate` finishing
* beside a running `portal serve` puts that live portal project into the user's `git status`.
* Failures are swallowed: a portal that has been written is not a crash.
*/
export async function removePortalProjectDirectoryBase(base: string, systemTemp: string = os.tmpdir()): Promise<void> {
if (base === systemTemp) {
return;
}
try {
const remaining = await fs.readdir(base);
if (remaining.some((entry) => entry !== '.gitignore')) {
return;
}
await fs.rm(path.join(base, '.gitignore'), { force: true });
await fs.rmdir(base);
} catch {
// Another run is using it, or it is already gone.
}
}
7 changes: 7 additions & 0 deletions src/prompts/portal/prepare-project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@ export class PreparePortalProjectPrompts {
log.error(reason);
}

public networkShareUnsupported(sourceDirectory: DirectoryPath) {
log.error(
`A portal can't be built or previewed from a network share, and ${f.path(sourceDirectory)} is on one. ` +
`Copy the folder to a local drive, or map the share to a drive letter, and use that path instead.`
);
}

public authorizationFailed(failure: PortalAuthorizationFailure) {
reportAuthorizationFailure(failure);
}
Expand Down
5 changes: 5 additions & 0 deletions src/types/file/directoryPath.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,11 @@ export class DirectoryPath {
return this.contains(other) && !this.isEqual(other);
}

/** On a Windows network share, named `\\server\share\…`; `\\?\` and `\\.\` start a local path. */
public isOnNetworkShare(): boolean {
return /^\\\\[^\\?.]/.test(this.directoryPath);
}

public leafName() {
return path.basename(this.directoryPath);
}
Expand Down
37 changes: 37 additions & 0 deletions test/actions/portal/prepare-project.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import os from 'os';
import sinon from 'sinon';
import { expect } from 'chai';
import { ok } from 'neverthrow';
import { PreparePortalProjectAction } from '../../../src/actions/portal/prepare-project';
import { PortalAuthorizationService } from '../../../src/infrastructure/services/portal-authorization-service';
import { DirectoryPath } from '../../../src/types/file/directoryPath';
import { CommandMetadata } from '../../../src/types/common/command-metadata';
import { ProjectContext } from '../../../src/types/project-context';
import { stubPreparePortalProject } from './prepare-project-stubs';

const COMMAND_METADATA: CommandMetadata = { commandName: 'portal generate', shell: 'test' };

// What `portal generate` and `portal serve` both run before either does its own part.
describe('PreparePortalProjectAction', () => {
afterEach(() => {
sinon.restore();
});

it('stops before signing in when the source is on a network share', async function () {
if (process.platform !== 'win32') this.skip();
const shared = stubPreparePortalProject();
const authorize = sinon.stub(PortalAuthorizationService.prototype, 'authorize').resolves(ok(undefined));
const onPrepared = sinon.stub();
const share = new DirectoryPath('\\\\server\\share\\api');

const result = await new PreparePortalProjectAction(new DirectoryPath(os.tmpdir()), COMMAND_METADATA).execute(
ProjectContext.in(share),
{ onPrepared }
);

expect(result.isFailed()).to.be.true;
expect(shared.prompts.networkShareUnsupported.calledOnceWith(share.join('src'))).to.be.true;
expect(authorize.called).to.be.false;
expect(onPrepared.called).to.be.false;
});
});
Loading
Loading