From c1c04efe1eb31ffde39317082a391ee7fbc6c592 Mon Sep 17 00:00:00 2001 From: saeedjamshaid Date: Fri, 16 Oct 2026 18:11:12 +0500 Subject: [PATCH 01/11] fix(portal): load a page's static images whatever drive they are on 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 --- .ai/plans/cross-drive-portal-projects.md | 266 +++++++++++++++++++++ portal-template/static-images.ts | 41 ++++ portal-template/vite.config.ts | 2 + test/portal-template/static-images.test.ts | 95 ++++++++ 4 files changed, 404 insertions(+) create mode 100644 .ai/plans/cross-drive-portal-projects.md create mode 100644 portal-template/static-images.ts create mode 100644 test/portal-template/static-images.test.ts diff --git a/.ai/plans/cross-drive-portal-projects.md b/.ai/plans/cross-drive-portal-projects.md new file mode 100644 index 000000000..b6d11914d --- /dev/null +++ b/.ai/plans/cross-drive-portal-projects.md @@ -0,0 +1,266 @@ +# Plan: build every portal project in the temp directory + +Status: designed 2026-10-16 on `saeedjamshaid/cross-drive-portal-styles`, cut from `origin/dev` at +`0faa568b`. Fixes apimatic-io#2301. A prototype of every change below was built and measured the +same day (section 4); this plan turns it into the change and its tests. Approved by the user on +2026-10-16, with the title in section 12. + +## 1. Goal and scope + +A portal built or previewed from an input on any Windows drive is the portal the same input gives +on the temp directory's drive: the same stylesheet, the same images, and nothing written into the +input folder. + +- **In scope:** the cross-drive fallback that stages the project in `\.apimatic-build\`, the + two upstream bugs it was dodging, and the Tailwind `@source` line that only that fallback needed. +- **Out of scope:** sources on a network share (section 10), and any other change to how the + project is laid out. +- **Invariant:** on macOS, Linux and same-drive Windows, `portal generate` writes the same files as + today. `portal serve` differs only in the address a page's static image loads from: + `/images/logo.png` (under the base) instead of `/@fs/`. + +## 2. Cause (verified 2026-10-16) + +When the input is on another drive than `%TEMP%`, `portalProjectDirectoryBase` stages the project +in `\.apimatic-build\tmp-*` and writes a `.gitignore` of `*` there +(`src/infrastructure/tmp-extensions.ts:35-62`, since #343). Vite runs with the project as its +working directory. Tailwind's scanner (Oxide 4.3.3, `crates/oxide/src/scanner/sources.rs`) then +handles the project root in one of two ways: + +- **Outside a git repository** it looks for an ignoring `.gitignore` only up to the directory that + holds the working directory, so the `*` never promotes the project. Its file walker still applies + the `*`, and it reads 0 files. The 24 utilities only the template uses are missing, which is the + report. +- **Inside a repository** it walks up to the repository root, finds the `*`, and promotes the + project to an "external" source. That scans everything, linked `node_modules` included: 561 + stray utilities, 93 KB → 134 KB. GitHub's Windows runner builds our own e2e portals this way. + +`@source '../../content'` scans nothing in the first case either. Explicit globs reach only the +files directly in `src/`: every subfolder still matches the `*` (section 3, D5). + +The fallback dates from #343, when fumadocs-mdx imported content pages by a path relative to the +project. Since #375 the content is copied into the project. What remains are two upstream Windows +bugs: + +1. **fumadocs-core `remarkImage`** imports a static image by `path.relative(pageDir, file)`. Across + drives that returns the absolute path, which `getImportPath` still prefixes with `./` + (`./D:/site/src/static/x.png`). A build only works by accident: Vite reads it as `D:site/...`, a + path relative to drive D:'s current directory, and the Vite child, whose environment is + filtered, has none. Not reported upstream; still on fumadocs `main`. +2. **Vite's dev server serves no raw file from another drive.** `/@fs/D:/x.png` drops the drive + and is served from the working directory's drive (vitejs/vite#12816, closed as not planned in + favour of #10802, open). Without the fallback, `portal serve` answers the image with its HTML + page. + +## 3. Decisions (2026-10-16) + +- **D1. One place for every portal project: the system temp directory.** + - `prepare-project.ts` stages it with `withDirPath`, as every other temp directory is staged. + - The fallback goes, with its `*` `.gitignore` and the shared-folder clean-up. + - On macOS, Linux and same-drive Windows this changes nothing: they already staged there. +- **D2. One template plugin, `static-images.ts`, for a page's static images.** + - It works around both upstream bugs of section 2, one hook each. + - *Resolve* (build and serve): the `./D:/…` or `.///server/share/…` specifier is resolved as the + absolute path it is. + - *Load* (serve only): a plain asset import from the static directory becomes its public address + (`withBasePath`). Vite serves its public directory from any drive. +- **D3. The public address on every drive and OS** (the user's choice, 2026-10-16). One behaviour, + so the macOS and Linux serve tests cover it. Same-drive previews stop using `/@fs/` for static + images. A query such as `?raw`, or a non-asset import, stays Vite's to answer. +- **D4. `app.css` drops `@source '../../content'`.** + - The copy is inside the project, so Tailwind's own scan reads it: a page's class survived on + every build in section 4 without the line. + - The unit test that held the literal is replaced by an e2e check that a page's class reaches + the stylesheet. That is the contract the literal stood for. +- **D5. Rejected:** + - *Explicit sources* (`source(none)` and globs): the `*` still hides every subfolder of `src/`. + Measured: 24 classes still lost. + - *Dropping the `.gitignore`*: the folder shows in `git status` for a whole `serve` session, and + the user's own ignore rules then apply to the staged files. A user who ignores + `.apimatic-build/` brings the over-scan back. + - *Running Vite from another working directory*: that promotes the project to an external source, + which is the over-scan again. +- **D6. Kept:** + - The static directory stays in `server.fs.allow`. A page's `?import` request for its image still + goes through `/@fs/`, and Safari can send it before the page (#2287). + - `COPIED_DEPENDENCIES` stays: it is about the CLI's install drive, which this does not move. + +## 4. What was verified (2026-10-16, prototype) + +The prototype switched the fallback off and added the plugin; `app.css` was as D4. Every +stylesheet was compared class for class with `dev`'s C: build of the same fixture +(`app-CtmRnplB.css`). The fixture is src (52) from #2301, plus static images (inline and +reference-style), a page-relative image, a page with a utility class, a logo, and nav links. + +| Case | `dev` | Prototype | +|---|---|---| +| Build, input on a USB drive (D:), no git | `app-CRXAw9v0.css` (#2301's, byte for byte), 25 classes lost | `app-CtmRnplB.css`, 0 lost, 0 gained | +| Same, input in a git repository | 561 classes gained | `app-CtmRnplB.css`, 0 lost, 0 gained | +| Build from a `subst` drive, with and without git | 24/25 lost; over-scan in git | identical to C: | +| `serve` from D: | template classes lost | image `200 image/png` from `/images/shot.png`; logo; full stylesheet | +| `serve` from D: under `site.url` `/docs/` | — | image from `/docs/images/shot.png` | +| Without the plugin, fallback off, from D: | — | build works by the drive-relative accident; `serve` answers the image with HTML | +| Editing a static image under `serve` | full reload | full reload | +| Portal e2e suite, input on D: | — | 66/67 | +| Portal e2e suite, project on D:, the CLI's dependencies on C: | — | 66/67, #2287's dependency test included | +| Headless Chrome, same layout: `/sdks`, `/images` | — | 0 failed requests, images loaded, `end-1.5` applied | +| CI's Linux job in Docker (`node:24`, as the `node` user), unit + e2e | 1917 passing | 1916 passing | + +The one e2e failure asks the dev server for a static image's raw `/@fs/` address, which pages no +longer use (7.2 rewrites it). The one Linux failure is the unit test holding the `@source` literal +(7.1 removes it). + +Not run: macOS (CI's job and a Safari check, section 8 step 5) and a source on a network share. + +## 5. CLI changes + +1. **`src/infrastructure/tmp-extensions.ts`.** + - Delete `PORTAL_PROJECT_DIRECTORY_NAME`, `portalProjectDirectoryBase`, + `ensurePortalProjectDirectoryBase`, `withPortalProjectDirectory`, + `removePortalProjectDirectoryBase`, and the imports only they use. + - `canonical` and `withDirPath` stay. +2. **`src/actions/portal/prepare-project.ts:101`.** + - `withPortalProjectDirectory(sourceDirectory, …)` becomes `withDirPath(…)`. + - No prompt, message, flag or README text changes: none mentions the folder. + +## 6. Template changes + +1. **`portal-template/static-images.ts`** (new, beside `downloads.ts`), as prototyped: + + ```ts + export function staticImages(staticDirectory: string | null): Plugin[] + ``` + + - **Resolve plugin:** `enforce: 'pre'` and a `resolveId` filter of `^\.\/(?:[A-Za-z]:\/|\/\/)`. + It answers `this.resolve(source.slice(2), importer, { ...options, skipSelf: true })`. + - **Serve plugin** (only when there is a static directory): + - `apply: 'serve'`, `enforce: 'pre'`. + - It reads `base` and `assetsInclude` from the resolved config. + - `load(id)` answers ``export default ""`` for an id under + the static directory with no query that Vite counts as an asset. It answers `null` + otherwise. + - The directory is matched in two spellings, as given and real (`realpathSync.native`). Vite + keeps a `subst` drive's spelling and gives everything else its real path (macOS `/var` is + `/private/var`). + - Comments: one line each, per `.ai/instructions.md`; the two upstream links carry the why. +2. **`portal-template/vite.config.ts`**: `staticImages(paths.staticDir)` after + `staticFunctionsBase()`. +3. **`portal-template/src/styles/app.css`**: drop the three lines of D4. + +## 7. Tests + +### 7.1 Unit + +- **`test/infrastructure/tmp-extensions.test.ts`**: + - Delete the `portalProjectDirectoryBase` and `withPortalProjectDirectory` suites, and the "for a + portal project too" case. + - Keep "the temp directory a run is given is spelled the way the filesystem spells it". +- **`test/portal-template/static-images.test.ts`** (new): + - The resolve filter takes `./D:/x.png`, `./d:/x.png` and `.///server/share/x.png`. It leaves + `./local.png`, `../x.png`, `D:/x.png` and `/images/x.png` alone. + - The handler resolves the path without its `./`, and skips itself. + - The serve plugin applies to `serve` only, and there is none without a static directory. + - An image under the static directory loads as `/images/x.png` at the root and as + `/docs/images/x.png` under `/docs/`. This holds in its real spelling too, through a directory + link made in the test. + - It answers `null` for an id with a query (`?raw`), a non-asset (`.json`), and a file outside + the directory. +- **`test/portal-template.test.ts:148-152`**: delete "has Tailwind scan the copy of the pages, by + its relative name"; 7.2 replaces it. + +### 7.2 End to end + +- **Helpers** (`portal-build.test.ts`, `portal-serve.test.ts`): + - The root becomes `canonical(fs.mkdtempSync(path.join(os.tmpdir(), …)))`, and `base` leaves + `BuiltPortal` and the clean-up. + - `canonical` matters on GitHub's Windows runner, whose `%TEMP%` is the short name + `C:\Users\RUNNER~1\…`. The CLI resolves it through `withDirPath`, and the tests must too. +- **Build:** "generates the template's utility classes and a page's own". + - The default fixture's `content/guides/intro.md` gains one element with an arbitrary-value class + no other file uses. + - The built `assets/app-*.css` holds that rule, and `end-1.5`'s (`CommandBlock`, template only). +- **Serve:** "serves a page's static image at its public address, asked for before the page" + replaces the raw-`/@fs/` case. + - It asks for `/@fs//images/logo.png?import` first and gets + `export default "/images/logo.png"`. + - That address answers `image/png`. + +On the Windows runner (workspace on D:, temp on C:) these run across drives for real, which +exercises both hooks of the plugin. On macOS and Linux they run the serve hook. + +## 8. Implementation steps + +Each step stops for review, and each commit waits to be asked for. + +1. **The plugin:** `static-images.ts`, its registration, and its unit tests (6.1, 6.2, 7.1). + Nothing changes for users yet: the fallback still keeps the static directory on the project's + drive. +2. **The fallback goes:** 5.1, 5.2, their unit tests, and the e2e helpers (7.2). +3. **The stylesheet:** 6.3, the packaging test's removal, and the two e2e cases (7.2). +4. **Self-review and verification:** + - Unit and e2e on Windows: C:, a `subst` drive, and the USB drive when it is attached. + - CI's job in Docker on `node:24` and `node:26`; lint and type check. + - The plans that describe the fallback get one dated line pointing here: + `fumadocs-portal.md` section 5 step 2 and its "Cross-drive builds" risk, and the Tailwind + paragraphs of `generated-pages.md`. +5. **Pull request into `dev`:** + - CI's matrix (ubuntu, windows, macos × Node 24, 26) and SonarCloud. + - Before merge, someone with a Mac opens a `portal serve` page with a static image in Safari. +6. **Follow-ups**, each drafted for approval before it is posted: + - a reply on apimatic-io#2301 (cause, what the comment's notes got wrong, the fix); + - a fumadocs issue for `remarkImage`'s cross-drive import path. + +## 9. Risks + +- **fumadocs changes its import path.** + - If it emits an absolute path, the resolve hook matches nothing and Vite resolves the path + itself. + - The serve hook keys on resolved ids under the static directory, so it holds either way. +- **Safari (#2287).** + - The image's module still comes through `/@fs/`, which stays allowed (D6). + - The image itself now comes from the public directory, which the allow list never covered. + Hence the manual check. +- **The Windows runner's e2e moves across drives.** Locally this passed from a real second drive + (section 4), and the helpers resolve the short-named temp directory (7.2). +- **A temp directory on removable media.** Vite's watcher can crash with `EBUSY` on freshly copied + files there (seen with `TEMP` on a USB stick). + - Today an *input* on a USB stick is staged on the stick and is just as flaky: an image the + build had just copied was not found. + - After this change only a temp directory on removable media is exposed. + +## 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 + public directory like any other path. + - Not run: there is no share on the machine this was measured on. +- **vitejs/vite#10802 in general.** A raw, non-JavaScript file that a dependency serves from another + drive than the project would still fail under `serve`. + - The template has none. Its fonts are copied (`COPIED_DEPENDENCIES`), and the rest goes through + Vite's transform, which reads any drive. + - The CLI's dependencies on another drive than `%TEMP%` passed the e2e and browser checks of + section 4. + +## 11. Upstream + +- **fumadocs-core `remarkImage`:** `getImportPath` should return an absolute (or `file://`) path + when `path.relative` does. The resolve hook goes once a fixed version is pinned. +- **vitejs/vite#10802:** the serve hook stays while that is open. + +## 12. Delivery + +- **Pull request** into `dev` from `saeedjamshaid/cross-drive-portal-styles`. Title (the squash + commit's header, chosen by the user): `fix(portal): keep the portal's styling when it is built from + another drive`. +- **Description:** the cause in user terms, what changed, what was verified, and `Fixes + apimatic-io#2301`. No internal detail, since this repository is public. +- **Worktrees:** `C:\repos\apimatic-cli-cross-drive` (this branch). The prototype worktree, + `C:\repos\apimatic-cli-proto`, is removed after step 4. + +## 13. History + +- **2026-10-16:** + - #2301's comment was checked, and its root cause confirmed. + - The fix was researched and prototyped (sections 2–4). + - The user chose the public address on every OS, a CI and Safari check for macOS, and drafts of + the #2301 reply and the fumadocs issue. diff --git a/portal-template/static-images.ts b/portal-template/static-images.ts new file mode 100644 index 000000000..253099174 --- /dev/null +++ b/portal-template/static-images.ts @@ -0,0 +1,41 @@ +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 `./`. + filter: { id: /^\.\/(?:[A-Za-z]:\/|\/\/)/ }, + handler(source, importer, options) { + return this.resolve(source.slice(2), importer, { ...options, skipSelf: true }); + } + } + }; + if (staticDirectory === null) return [resolve]; + + // Vite names a module by its real path, but keeps the spelling of a `subst` drive. + const directories = [staticDirectory, realpathSync.native(staticDirectory)].map((dir) => `${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) => id.startsWith(dir)); + // A query such as `?raw` asks for more than the address, and stays Vite's to answer. + if (directory === undefined || id.includes('?') || !config.assetsInclude(id)) return null; + return `export default ${JSON.stringify(withBasePath(`/${id.slice(directory.length)}`, config.base))};`; + } + } + ]; +} diff --git a/portal-template/vite.config.ts b/portal-template/vite.config.ts index 27025c9d4..821955faf 100644 --- a/portal-template/vite.config.ts +++ b/portal-template/vite.config.ts @@ -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()]); @@ -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: { diff --git a/test/portal-template/static-images.test.ts b/test/portal-template/static-images.test.ts new file mode 100644 index 000000000..413388325 --- /dev/null +++ b/test/portal-template/static-images.test.ts @@ -0,0 +1,95 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { expect } from 'chai'; +import { normalizePath, type Plugin } from 'vite'; +import { staticImages } from '../../portal-template/static-images'; + +type Resolve = (source: string, importer: string | undefined, options: object) => unknown; + +interface ResolveHook { + filter: { id: RegExp }; + handler: (this: { resolve: Resolve }, source: string, importer: string | undefined, options: object) => unknown; +} + +const resolveHook = (plugins: Plugin[]) => plugins[0].resolveId as unknown as ResolveHook; + +/** The preview's plugin for a portal served under `base`, where Vite counts a `.png` as an asset. */ +function previewLoad(staticDirectory: string, base = '/'): (id: string) => string | null { + const preview = staticImages(staticDirectory)[1]; + const configResolved = preview.configResolved as (config: object) => void; + configResolved({ base, assetsInclude: (file: string) => file.endsWith('.png') }); + return preview.load as (id: string) => string | null; +} + +describe('staticImages', () => { + let root: string; + let real: string; + let linked: string; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'static-images-')); + fs.mkdirSync(path.join(root, 'static')); + real = normalizePath(fs.realpathSync.native(path.join(root, 'static'))); + linked = path.join(root, 'linked'); + fs.symlinkSync(path.join(root, 'static'), linked, process.platform === 'win32' ? 'junction' : 'dir'); + }); + + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('resolves the absolute path Fumadocs puts behind a `./` across Windows drives', () => { + const hook = resolveHook(staticImages(null)); + const asked: unknown[][] = []; + const resolve: Resolve = (...args) => { + asked.push(args); + return 'resolved'; + }; + + for (const source of ['./D:/site/x.png', './d:/site/x.png', './//server/share/x.png']) { + expect(hook.filter.id.test(source), source).to.be.true; + } + const resolved = hook.handler.call({ resolve }, './D:/site/src/static/x.png', 'C:/project/content/page.md', { + isEntry: false + }); + + expect(resolved).to.equal('resolved'); + expect(asked).to.deep.equal([ + ['D:/site/src/static/x.png', 'C:/project/content/page.md', { isEntry: false, skipSelf: true }] + ]); + }); + + it('leaves every other import to Vite', () => { + const { filter } = resolveHook(staticImages(null)); + + for (const source of ['./local.png', '../static/x.png', 'D:/site/x.png', '/images/x.png', 'fumadocs-ui']) { + expect(filter.id.test(source), source).to.be.false; + } + }); + + it('changes only the preview, and only for a portal with a static directory', () => { + expect(staticImages(null).map((plugin) => plugin.name)).to.deep.equal(['apimatic:static-images:resolve']); + expect(staticImages(linked)[1].apply).to.equal('serve'); + }); + + it('loads a static image as the address the public directory serves it at, under the base', () => { + expect(previewLoad(real)(`${real}/images/x.png`)).to.equal('export default "/images/x.png";'); + expect(previewLoad(real, '/docs/')(`${real}/images/x.png`)).to.equal('export default "/docs/images/x.png";'); + }); + + it('knows the static directory by its own spelling and by its real one', () => { + const load = previewLoad(linked); + + expect(load(`${normalizePath(linked)}/images/x.png`)).to.equal('export default "/images/x.png";'); + expect(load(`${real}/images/x.png`)).to.equal('export default "/images/x.png";'); + }); + + it('leaves a query, a file that is no asset and a file elsewhere to Vite', () => { + const load = previewLoad(real); + + expect(load(`${real}/images/x.png?raw`)).to.be.null; + expect(load(`${real}/data.json`)).to.be.null; + expect(load(`${normalizePath(root)}/elsewhere/x.png`)).to.be.null; + }); +}); From 341d2f30b0d4e451062a9c6b06c8c1ab80869bc6 Mon Sep 17 00:00:00 2001 From: saeedjamshaid Date: Fri, 16 Oct 2026 19:07:01 +0500 Subject: [PATCH 02/11] fix(portal): build every portal project in the system temp directory On Windows, an input on another drive than the temp directory had its portal project staged in `/.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 --- .ai/plans/cross-drive-portal-projects.md | 6 +- src/actions/portal/prepare-project.ts | 4 +- src/infrastructure/tmp-extensions.ts | 83 ------------------- test/e2e/portal-build.test.ts | 26 ++---- test/e2e/portal-serve.test.ts | 18 ++-- test/infrastructure/tmp-extensions.test.ts | 95 +--------------------- 6 files changed, 25 insertions(+), 207 deletions(-) diff --git a/.ai/plans/cross-drive-portal-projects.md b/.ai/plans/cross-drive-portal-projects.md index b6d11914d..53d100d15 100644 --- a/.ai/plans/cross-drive-portal-projects.md +++ b/.ai/plans/cross-drive-portal-projects.md @@ -195,8 +195,10 @@ Each step stops for review, and each commit waits to be asked for. 1. **The plugin:** `static-images.ts`, its registration, and its unit tests (6.1, 6.2, 7.1). Nothing changes for users yet: the fallback still keeps the static directory on the project's drive. -2. **The fallback goes:** 5.1, 5.2, their unit tests, and the e2e helpers (7.2). -3. **The stylesheet:** 6.3, the packaging test's removal, and the two e2e cases (7.2). +2. **The fallback goes:** 5.1, 5.2, their unit tests, the e2e helpers, and the serve case of 7.2. + The serve case moved here from step 3 (2026-10-16), because this is the step that makes the old + raw-`/@fs/` case fail across drives; every step then passes on every layout. +3. **The stylesheet:** 6.3, the packaging test's removal, and the build case of 7.2. 4. **Self-review and verification:** - Unit and e2e on Windows: C:, a `subst` drive, and the USB drive when it is attached. - CI's job in Docker on `node:24` and `node:26`; lint and type check. diff --git a/src/actions/portal/prepare-project.ts b/src/actions/portal/prepare-project.ts index 9f2f16ea4..c5004fffa 100644 --- a/src/actions/portal/prepare-project.ts +++ b/src/actions/portal/prepare-project.ts @@ -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'; @@ -98,7 +98,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); diff --git a/src/infrastructure/tmp-extensions.ts b/src/infrastructure/tmp-extensions.ts index 073676328..f54567efd 100644 --- a/src/infrastructure/tmp-extensions.ts +++ b/src/infrastructure/tmp-extensions.ts @@ -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'; @@ -22,83 +19,3 @@ export function canonical(directory: string): string { export function withDirPath(fn: (results: DirectoryPath) => Promise): Promise { 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 { - 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( - sourceDirectory: DirectoryPath, - fn: (directory: DirectoryPath) => Promise, - systemTemp: string = os.tmpdir() -): Promise { - 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 { - 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. - } -} diff --git a/test/e2e/portal-build.test.ts b/test/e2e/portal-build.test.ts index 2aea5eae4..c0d20b8dc 100644 --- a/test/e2e/portal-build.test.ts +++ b/test/e2e/portal-build.test.ts @@ -1,4 +1,5 @@ import fs from 'fs'; +import os from 'os'; import path from 'path'; import { createRequire } from 'node:module'; import { execa } from 'execa'; @@ -12,10 +13,7 @@ import { FilePath } from '../../src/types/file/filePath'; import { CodeSampleCatalog, CodeSampleCatalogs } from '../../src/types/portal/code-samples'; import { PortalArtifacts } from '../../src/types/portal/portal-artifacts'; import { Language } from '../../src/types/sdk/generate'; -import { - ensurePortalProjectDirectoryBase, - removePortalProjectDirectoryBase -} from '../../src/infrastructure/tmp-extensions'; +import { canonical } from '../../src/infrastructure/tmp-extensions'; // A real Vite build takes tens of seconds and needs every runtime dependency installed, // so it stays out of the default run. CI switches it on for the platform matrix. @@ -38,7 +36,6 @@ const SDK_DOCS = 'Use **identical method names**, **(identical)** and *`code`* here.\n'; interface BuiltPortal { - base: string; root: string; project: DirectoryPath; output: DirectoryPath; @@ -70,8 +67,7 @@ function deliveredInto( /** Resolves, prepares, builds and saves a fixture as `portal generate` does. */ async function buildFixture(name: string, delivered: Delivered = {}): Promise { const fixture = new DirectoryPath(process.cwd()).join('test/resources/portal-inputs').join(name).join('src'); - const base = await ensurePortalProjectDirectoryBase(fixture); - const root = fs.mkdtempSync(path.join(base, 'portal-e2e-')); + const root = canonical(fs.mkdtempSync(path.join(os.tmpdir(), 'portal-e2e-'))); const source = (await new PortalSourceContext(fixture).resolve())._unsafeUnwrap(); @@ -89,13 +85,11 @@ async function buildFixture(name: string, delivered: Delivered = {}): Promise { - if (built === undefined) return; - fs.rmSync(built.root, { recursive: true, force: true }); - await removePortalProjectDirectoryBase(built.base); +function removeBuilt(built: BuiltPortal | undefined): void { + if (built !== undefined) fs.rmSync(built.root, { recursive: true, force: true }); } /** @@ -113,9 +107,7 @@ function portalBuilt(name: string, delivered: Delivered = {}) { built = await buildFixture(name, delivered); }); - after(async () => { - await removeBuilt(built); - }); + after(() => removeBuilt(built)); return { output: () => have().output, @@ -745,9 +737,7 @@ const stylesheetOf = (output: DirectoryPath) => { ({ output } = built); }); - after(async () => { - await removeBuilt(built); - }); + after(() => removeBuilt(built)); const read = (relative: string) => fs.readFileSync(path.join(output.toString(), relative), 'utf8'); const exists = (relative: string) => fs.existsSync(path.join(output.toString(), relative)); diff --git a/test/e2e/portal-serve.test.ts b/test/e2e/portal-serve.test.ts index b02122dde..093eca0ff 100644 --- a/test/e2e/portal-serve.test.ts +++ b/test/e2e/portal-serve.test.ts @@ -1,4 +1,5 @@ import fs from 'fs'; +import os from 'os'; import path from 'path'; import { expect } from 'chai'; import getPort from 'get-port'; @@ -9,10 +10,7 @@ import { DirectoryPath } from '../../src/types/file/directoryPath'; import { FilePath } from '../../src/types/file/filePath'; import { CodeSampleCatalogs } from '../../src/types/portal/code-samples'; import { PortalArtifacts } from '../../src/types/portal/portal-artifacts'; -import { - ensurePortalProjectDirectoryBase, - removePortalProjectDirectoryBase -} from '../../src/infrastructure/tmp-extensions'; +import { canonical } from '../../src/infrastructure/tmp-extensions'; // Opt-in: a real dev server needs a minute and every runtime dependency. const enabled = process.env.APIMATIC_E2E === '1'; @@ -21,13 +19,11 @@ const enabled = process.env.APIMATIC_E2E === '1'; this.timeout(10 * 60 * 1000); const fixture = new DirectoryPath(process.cwd()).join('test/resources/portal-inputs/default/src'); - let base: string | undefined; let root: string | undefined; let server: PortalDevServer | undefined; before(async () => { - base = await ensurePortalProjectDirectoryBase(fixture); - root = fs.mkdtempSync(path.join(base, 'portal-serve-e2e-')); + root = canonical(fs.mkdtempSync(path.join(os.tmpdir(), 'portal-serve-e2e-'))); const source = (await new PortalSourceContext(fixture).resolve())._unsafeUnwrap(); const delivered = path.join(root, 'delivered'); @@ -55,7 +51,6 @@ const enabled = process.env.APIMATIC_E2E === '1'; after(async () => { await server?.stop(); if (root !== undefined) fs.rmSync(root, { recursive: true, force: true }); - if (base !== undefined) await removePortalProjectDirectoryBase(base); }); // A missing file answers 200 too, with the fallback page, so the type says what came back. @@ -69,14 +64,17 @@ const enabled = process.env.APIMATIC_E2E === '1'; expect(response.headers.get('content-type')).to.contain('javascript'); }); - it('serves an image from the static directory asked for before the page importing it', async () => { + // The image comes from the static directory by its address, which Vite serves from any drive. + it("serves a page's static image at its public address, its module asked for before the page", async () => { const image = fs .realpathSync(path.join(fixture.toString(), 'static', 'images', 'logo.png')) .split(path.sep) .join('/'); - const response = await globalThis.fetch(`${server?.url.toString()}/@fs/${image}`); + const module = await globalThis.fetch(`${server?.url.toString()}/@fs/${image}?import`); + const response = await globalThis.fetch(`${server?.url.toString()}/images/logo.png`); + expect(await module.text()).to.contain('export default "/images/logo.png"'); expect(response.status).to.equal(200); expect(response.headers.get('content-type')).to.equal('image/png'); }); diff --git a/test/infrastructure/tmp-extensions.test.ts b/test/infrastructure/tmp-extensions.test.ts index 788c306b5..b584cb95a 100644 --- a/test/infrastructure/tmp-extensions.test.ts +++ b/test/infrastructure/tmp-extensions.test.ts @@ -1,40 +1,7 @@ import fs from 'fs'; -import os from 'os'; import path from 'path'; import { expect } from 'chai'; -import { - canonical, - PORTAL_PROJECT_DIRECTORY_NAME, - portalProjectDirectoryBase, - withDirPath, - withPortalProjectDirectory -} from '../../src/infrastructure/tmp-extensions'; -import { DirectoryPath } from '../../src/types/file/directoryPath'; - -describe('portalProjectDirectoryBase', () => { - // A plain string, not a DirectoryPath: that class resolves against the host's own path - // rules, and this Windows path is checked on every platform. - const source = 'D:\\work\\my-api\\src'; - - it('uses the system temp directory when it shares the drive with the source', () => { - expect(portalProjectDirectoryBase(source, 'D:\\Temp', 'win32')).to.equal('D:\\Temp'); - }); - - it('compares drive letters without regard to case', () => { - expect(portalProjectDirectoryBase(source, 'd:\\Temp', 'win32')).to.equal('d:\\Temp'); - }); - - it('falls back to a folder beside the source when the temp directory is on another drive', () => { - expect(portalProjectDirectoryBase(source, 'C:\\Users\\me\\AppData\\Local\\Temp', 'win32')).to.equal( - path.win32.join('D:\\work\\my-api', PORTAL_PROJECT_DIRECTORY_NAME) - ); - }); - - it('never leaves the system temp directory on other platforms', () => { - expect(portalProjectDirectoryBase('/work/my-api/src', '/tmp', 'linux')).to.equal('/tmp'); - expect(portalProjectDirectoryBase('/work/my-api/src', '/tmp', 'darwin')).to.equal('/tmp'); - }); -}); +import { withDirPath } from '../../src/infrastructure/tmp-extensions'; // The portal's dev server resolves its allow-list to real paths and then compares strings, so a // temp directory named any other way than its real one has every page under it refused. @@ -45,21 +12,10 @@ describe('the temp directory a run is given', () => { }); }); - it('is spelled that way for a portal project too', async () => { - const source = new DirectoryPath(fs.mkdtempSync(path.join(os.tmpdir(), 'build-real-'))).join('src'); - - await withPortalProjectDirectory(source, async (directory) => { - expect(directory.toString()).to.equal(fs.realpathSync.native(directory.toString())); - }); - }); -}); - -describe('withPortalProjectDirectory', () => { - it('hands out a directory that exists while the callback runs and is gone afterwards', async () => { - const source = new DirectoryPath(fs.mkdtempSync(path.join(os.tmpdir(), 'build-source-'))).join('src'); + it('exists while the callback runs and is gone afterwards', async () => { let seen: string | undefined; - await withPortalProjectDirectory(source, async (directory) => { + await withDirPath(async (directory) => { seen = directory.toString(); expect(fs.existsSync(seen)).to.be.true; fs.writeFileSync(path.join(seen, 'marker.txt'), 'x'); @@ -68,49 +24,4 @@ describe('withPortalProjectDirectory', () => { expect(seen).to.be.a('string'); expect(fs.existsSync(seen as string)).to.be.false; }); - - // Drive letters only exist on Windows, so the fallback can only be exercised for real there. - (process.platform === 'win32' ? it : it.skip)( - 'builds beside the source when the temp directory is on another drive, and cleans up', - async () => { - const project = canonical(fs.mkdtempSync(path.join(os.tmpdir(), 'build-project-'))); - const source = new DirectoryPath(project).join('src'); - const foreignTemp = 'Z:\\Temp'; - const fallback = path.join(project, PORTAL_PROJECT_DIRECTORY_NAME); - - await withPortalProjectDirectory( - source, - async (directory) => { - expect(directory.toString().toLowerCase().startsWith(fallback.toLowerCase())).to.be.true; - expect(fs.readFileSync(path.join(fallback, '.gitignore'), 'utf8')).to.equal('*\n'); - }, - foreignTemp - ); - - expect(fs.existsSync(fallback)).to.be.false; - } - ); - - (process.platform === 'win32' ? it : it.skip)( - 'leaves the shared folder alone while another run is in it', - async () => { - const project = canonical(fs.mkdtempSync(path.join(os.tmpdir(), 'build-shared-'))); - const source = new DirectoryPath(project).join('src'); - const fallback = path.join(project, PORTAL_PROJECT_DIRECTORY_NAME); - - await withPortalProjectDirectory( - source, - async () => { - // Stands in for a second run still building in the same folder. - fs.mkdirSync(path.join(fallback, 'another-run'), { recursive: true }); - }, - 'Z:\\Temp' - ); - - expect(fs.existsSync(path.join(fallback, '.gitignore')), 'the marker survives').to.be.true; - expect(fs.existsSync(path.join(fallback, 'another-run')), "the other run's directory survives").to.be.true; - - fs.rmSync(project, { recursive: true, force: true }); - } - ); }); From b198b89cd5e70291c47ff5c1cb19af7684e7dc37 Mon Sep 17 00:00:00 2001 From: saeedjamshaid Date: Fri, 16 Oct 2026 19:18:16 +0500 Subject: [PATCH 03/11] fix(portal): let Tailwind find a page's classes by itself `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 --- .ai/plans/cross-drive-portal-projects.md | 12 ++++++++---- portal-template/src/styles/app.css | 4 ---- test/e2e/portal-build.test.ts | 6 ++++++ test/portal-template.test.ts | 7 ------- .../{authentication.md => authentication.mdx} | 2 ++ 5 files changed, 16 insertions(+), 15 deletions(-) rename test/resources/portal-inputs/default/src/content/{authentication.md => authentication.mdx} (55%) diff --git a/.ai/plans/cross-drive-portal-projects.md b/.ai/plans/cross-drive-portal-projects.md index 53d100d15..27769a745 100644 --- a/.ai/plans/cross-drive-portal-projects.md +++ b/.ai/plans/cross-drive-portal-projects.md @@ -175,10 +175,14 @@ Not run: macOS (CI's job and a Safari check, section 8 step 5) and a source on a `BuiltPortal` and the clean-up. - `canonical` matters on GitHub's Windows runner, whose `%TEMP%` is the short name `C:\Users\RUNNER~1\…`. The CLI resolves it through `withDirPath`, and the tests must too. -- **Build:** "generates the template's utility classes and a page's own". - - The default fixture's `content/guides/intro.md` gains one element with an arbitrary-value class - no other file uses. - - The built `assets/app-*.css` holds that rule, and `end-1.5`'s (`CommandBlock`, template only). +- **Build:** "generates the utility classes a page uses". + - The default fixture's `content/authentication.md` becomes `authentication.mdx`, with one + element carrying an arbitrary-value class no other file uses (`tracking-[0.3125em]`). Content + pages have no `rehype-raw`, so only MDX renders an author's class. No test names that page, + whereas `guides/` is rewritten by the serve unit tests. + - The page carries the class and the built stylesheet holds its rule. + - The template's own classes need no new case: the branded build already asserts `dark:block`, a + template-only class #2301 lost (step 3, 2026-10-16). - **Serve:** "serves a page's static image at its public address, asked for before the page" replaces the raw-`/@fs/` case. - It asks for `/@fs//images/logo.png?import` first and gets diff --git a/portal-template/src/styles/app.css b/portal-template/src/styles/app.css index 6770b3918..f0a62366a 100644 --- a/portal-template/src/styles/app.css +++ b/portal-template/src/styles/app.css @@ -19,10 +19,6 @@ @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. */ -@source '../../content'; - html { scrollbar-gutter: stable; } diff --git a/test/e2e/portal-build.test.ts b/test/e2e/portal-build.test.ts index c0d20b8dc..257cd880a 100644 --- a/test/e2e/portal-build.test.ts +++ b/test/e2e/portal-build.test.ts @@ -483,6 +483,12 @@ const stylesheetOf = (output: DirectoryPath) => { expect(css).to.match(/--color-fd-primary:#171717/); }); + // 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', () => { + expect(read('authentication/index.html')).to.contain('class="tracking-[0.3125em]"'); + expect(stylesheetOf(portal.output())).to.contain('.tracking-\\[0\\.3125em\\]'); + }); + // The counterpart of the branded portal's forced mode: the same label, present here. it('offers the colour-mode switch when both modes are allowed', () => { expect(read('index.html')).to.contain('aria-label="Toggle Theme"'); diff --git a/test/portal-template.test.ts b/test/portal-template.test.ts index a7b5b286f..b389f7113 100644 --- a/test/portal-template.test.ts +++ b/test/portal-template.test.ts @@ -145,13 +145,6 @@ describe('portal template packaging', () => { } }); - // Named to Tailwind from `src/styles/`; a rename that missed it would cost every utility class in a page, silently. - it('has Tailwind scan the copy of the pages, by its relative name', () => { - const stylesheet = fs.readFileSync(path.join(templateRoot, 'src/styles/app.css'), 'utf8'); - - expect(stylesheet).to.contain(`@source '../../${CONTENT_COPY_DIRECTORY_NAME}';`); - }); - // Left to itself the popover reads `window.location.href`, so a reader's query string and hash // — tracking and preview parameters among them — reach the AI vendors it links out to. it('sends the AI vendors the page address and nothing the reader carried with them', () => { diff --git a/test/resources/portal-inputs/default/src/content/authentication.md b/test/resources/portal-inputs/default/src/content/authentication.mdx similarity index 55% rename from test/resources/portal-inputs/default/src/content/authentication.md rename to test/resources/portal-inputs/default/src/content/authentication.mdx index baa5213a0..14290046b 100644 --- a/test/resources/portal-inputs/default/src/content/authentication.md +++ b/test/resources/portal-inputs/default/src/content/authentication.mdx @@ -4,3 +4,5 @@ description: How requests are authorised. --- Authentication for the fixture. + +

Spaced out by a class only this page uses.

From 494ffaa5527df40d15a64f05690d3d8a395d50aa Mon Sep 17 00:00:00 2001 From: saeedjamshaid Date: Fri, 16 Oct 2026 19:26:13 +0500 Subject: [PATCH 04/11] docs(portal): point the plans that described the cross-drive fallback at its replacement Co-Authored-By: Claude Opus 5.5 --- .ai/plans/fumadocs-portal.md | 5 +++++ .ai/plans/generated-pages.md | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/.ai/plans/fumadocs-portal.md b/.ai/plans/fumadocs-portal.md index 928138f62..ef5bc0f4f 100644 --- a/.ai/plans/fumadocs-portal.md +++ b/.ai/plans/fumadocs-portal.md @@ -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 @@ -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 diff --git a/.ai/plans/generated-pages.md b/.ai/plans/generated-pages.md index 1b388dea9..ad6a79506 100644 --- a/.ai/plans/generated-pages.md +++ b/.ai/plans/generated-pages.md @@ -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` @@ -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 From 61d56196c8cab207ce9ec39ef97d8165ba1258d3 Mon Sep 17 00:00:00 2001 From: saeedjamshaid Date: Fri, 16 Oct 2026 19:40:15 +0500 Subject: [PATCH 05/11] docs(portal): record what the cross-drive fix was verified on Co-Authored-By: Claude Opus 5.5 --- .ai/plans/cross-drive-portal-projects.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.ai/plans/cross-drive-portal-projects.md b/.ai/plans/cross-drive-portal-projects.md index 27769a745..eeb4697fd 100644 --- a/.ai/plans/cross-drive-portal-projects.md +++ b/.ai/plans/cross-drive-portal-projects.md @@ -5,6 +5,9 @@ Status: designed 2026-10-16 on `saeedjamshaid/cross-drive-portal-styles`, cut fr same day (section 4); this plan turns it into the change and its tests. Approved by the user on 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. + ## 1. Goal and scope A portal built or previewed from an input on any Windows drive is the portal the same input gives @@ -270,3 +273,13 @@ Each step stops for review, and each commit waits to be asked for. - The fix was researched and prototyped (sections 2–4). - The user chose the public address on every OS, a CI and Safari check for macOS, and drafts of the #2301 reply and the fumadocs issue. + - Steps 1–4 implemented. The branch passed: + - CI's job on Windows: 1919 passing, lint clean; + - the e2e suites from a `subst` drive and from a USB drive: 68/68 each; + - CI's job in Docker on `node:24` and `node:26`: 1918 passing each, lint clean. + - The final review found nothing to change, and two things to watch: + - CI's macOS job: the e2e roots now carry their real path (`/private/var/...`). The check that + no build-machine path reaches the output now searches for the spelling Vite embeds there, so + it is stricter on macOS than before. + - PR #437 bumps fumadocs-core to 16.17.1, whose `getImportPath` is unchanged, so the resolve + hook still applies. From 586f226af22b371e82bc2f9cad96bd53eddf9d44 Mon Sep 17 00:00:00 2001 From: saeedjamshaid Date: Mon, 19 Oct 2026 15:38:24 +0500 Subject: [PATCH 06/11] fix(portal): refuse a portal source on a network share 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 --- portal-template/static-images.ts | 4 ++-- src/actions/portal/prepare-project.ts | 6 ++++++ src/prompts/portal/prepare-project.ts | 7 +++++++ src/types/file/directoryPath.ts | 5 +++++ test/actions/portal/generate.test.ts | 11 +++++++++++ test/actions/portal/serve.test.ts | 11 +++++++++++ test/portal-template/static-images.test.ts | 13 +++++++++++-- test/types/file/directory-path.test.ts | 11 +++++++++++ 8 files changed, 64 insertions(+), 4 deletions(-) diff --git a/portal-template/static-images.ts b/portal-template/static-images.ts index 253099174..7d5cd510d 100644 --- a/portal-template/static-images.ts +++ b/portal-template/static-images.ts @@ -8,8 +8,8 @@ export function staticImages(staticDirectory: string | null): Plugin[] { name: 'apimatic:static-images:resolve', enforce: 'pre', resolveId: { - // Fumadocs' import of a file on another drive: the absolute path, still behind a `./`. - filter: { id: /^\.\/(?:[A-Za-z]:\/|\/\/)/ }, + // 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 }); } diff --git a/src/actions/portal/prepare-project.ts b/src/actions/portal/prepare-project.ts index c5004fffa..fe380f6b5 100644 --- a/src/actions/portal/prepare-project.ts +++ b/src/actions/portal/prepare-project.ts @@ -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, diff --git a/src/prompts/portal/prepare-project.ts b/src/prompts/portal/prepare-project.ts index 78ae3a211..a9b65963e 100644 --- a/src/prompts/portal/prepare-project.ts +++ b/src/prompts/portal/prepare-project.ts @@ -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); } diff --git a/src/types/file/directoryPath.ts b/src/types/file/directoryPath.ts index b50822dbe..b7c281978 100644 --- a/src/types/file/directoryPath.ts +++ b/src/types/file/directoryPath.ts @@ -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); } diff --git a/test/actions/portal/generate.test.ts b/test/actions/portal/generate.test.ts index c1e5f15d3..9d8a6688c 100644 --- a/test/actions/portal/generate.test.ts +++ b/test/actions/portal/generate.test.ts @@ -150,6 +150,17 @@ describe('GenerateAction', () => { expect(authorize.called).to.be.false; }); + it('stops before signing in when the source is on a network share', async function () { + if (process.platform !== 'win32') this.skip(); + const share = new DirectoryPath('\\\\server\\share\\api'); + + const result = await execute(share); + + expect(result.isFailed()).to.be.true; + expect(shared.prompts.networkShareUnsupported.calledOnceWith(share.join('src'))).to.be.true; + expect(authorize.called).to.be.false; + }); + it('fails when the account may not build a portal, without starting a build', async () => { authorize.resolves(err({ kind: 'notEntitled' as const })); diff --git a/test/actions/portal/serve.test.ts b/test/actions/portal/serve.test.ts index f29e5d5c8..e3b8def04 100644 --- a/test/actions/portal/serve.test.ts +++ b/test/actions/portal/serve.test.ts @@ -103,6 +103,17 @@ describe('PortalServeAction', () => { expect(authorize.called).to.be.false; }); + it('stops before signing in when the source is on a network share', async function () { + if (process.platform !== 'win32') this.skip(); + const share = new DirectoryPath('\\\\server\\share\\api'); + + const result = await execute(share); + + expect(result.isFailed()).to.be.true; + expect(shared.prompts.networkShareUnsupported.calledOnceWith(share.join('src'))).to.be.true; + expect(authorize.called).to.be.false; + }); + it('fails when the account may not build a portal, without starting the preview', async () => { authorize.resolves(err({ kind: 'notEntitled' as const })); diff --git a/test/portal-template/static-images.test.ts b/test/portal-template/static-images.test.ts index 413388325..6e453df66 100644 --- a/test/portal-template/static-images.test.ts +++ b/test/portal-template/static-images.test.ts @@ -47,7 +47,7 @@ describe('staticImages', () => { return 'resolved'; }; - for (const source of ['./D:/site/x.png', './d:/site/x.png', './//server/share/x.png']) { + for (const source of ['./D:/site/x.png', './d:/site/x.png']) { expect(hook.filter.id.test(source), source).to.be.true; } const resolved = hook.handler.call({ resolve }, './D:/site/src/static/x.png', 'C:/project/content/page.md', { @@ -60,10 +60,19 @@ describe('staticImages', () => { ]); }); + // The CLI builds no portal from a network share, so Fumadocs' `.///server/share/…` is not taken either. it('leaves every other import to Vite', () => { const { filter } = resolveHook(staticImages(null)); - for (const source of ['./local.png', '../static/x.png', 'D:/site/x.png', '/images/x.png', 'fumadocs-ui']) { + for (const source of [ + './local.png', + './/local.png', + '../static/x.png', + 'D:/site/x.png', + '/images/x.png', + 'fumadocs-ui', + './//server/share/x.png' + ]) { expect(filter.id.test(source), source).to.be.false; } }); diff --git a/test/types/file/directory-path.test.ts b/test/types/file/directory-path.test.ts index 8002e24fa..8419de254 100644 --- a/test/types/file/directory-path.test.ts +++ b/test/types/file/directory-path.test.ts @@ -30,6 +30,17 @@ describe('DirectoryPath.relativeTo', () => { }); }); +describe('DirectoryPath.isOnNetworkShare', () => { + it('holds a Windows share, however its slashes are typed, and no local path', function () { + if (process.platform !== 'win32') this.skip(); + + expect(new DirectoryPath('\\\\server\\share\\api').isOnNetworkShare()).to.equal(true); + expect(new DirectoryPath('//server/share/api').isOnNetworkShare()).to.equal(true); + expect(new DirectoryPath('C:\\api').isOnNetworkShare()).to.equal(false); + expect(new DirectoryPath('\\\\?\\C:\\api').isOnNetworkShare()).to.equal(false); + }); +}); + // What a file the build reads, and a log a reader compares to one, spell a path as on every platform. describe('DirectoryPath.toPosix', () => { it('keeps the whole path with forward slashes between its segments', () => { From 02e4857c35904844f0862cd041a5e7e7500020ec Mon Sep 17 00:00:00 2001 From: saeedjamshaid Date: Mon, 19 Oct 2026 15:38:55 +0500 Subject: [PATCH 07/11] fix(portal): match the static directory in any case on Windows 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 --- portal-template/static-images.ts | 11 ++++++++--- test/portal-template/static-images.test.ts | 6 ++++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/portal-template/static-images.ts b/portal-template/static-images.ts index 7d5cd510d..4e244c97a 100644 --- a/portal-template/static-images.ts +++ b/portal-template/static-images.ts @@ -17,8 +17,12 @@ export function staticImages(staticDirectory: string | null): Plugin[] { }; 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) => `${normalizePath(dir)}/`); + const directories = [staticDirectory, realpathSync.native(staticDirectory)].map((dir) => + folded(`${normalizePath(dir)}/`) + ); let config: ResolvedConfig; return [ resolve, @@ -31,10 +35,11 @@ export function staticImages(staticDirectory: string | null): Plugin[] { }, // 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)); + 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. if (directory === undefined || id.includes('?') || !config.assetsInclude(id)) return null; - return `export default ${JSON.stringify(withBasePath(`/${id.slice(directory.length)}`, config.base))};`; + const address = withBasePath(`/${id.slice(directory.length)}`, config.base); + return `export default ${JSON.stringify(address)};`; } } ]; diff --git a/test/portal-template/static-images.test.ts b/test/portal-template/static-images.test.ts index 6e453df66..557cf64d5 100644 --- a/test/portal-template/static-images.test.ts +++ b/test/portal-template/static-images.test.ts @@ -94,6 +94,12 @@ describe('staticImages', () => { expect(load(`${real}/images/x.png`)).to.equal('export default "/images/x.png";'); }); + it('knows the static directory on Windows whatever the case it is spelled in', function () { + if (process.platform !== 'win32') this.skip(); + + expect(previewLoad(real)(`${real.toUpperCase()}/images/x.png`)).to.equal('export default "/images/x.png";'); + }); + it('leaves a query, a file that is no asset and a file elsewhere to Vite', () => { const load = previewLoad(real); From 6cd6664b86627482356e7be2b5f55bb08c2e7d7c Mon Sep 17 00:00:00 2001 From: saeedjamshaid Date: Mon, 19 Oct 2026 15:39:01 +0500 Subject: [PATCH 08/11] fix(portal): name the folders Tailwind scans 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 --- portal-template/src/styles/app.css | 8 ++++++-- test/e2e/portal-build.test.ts | 14 +++++++++----- test/portal-template.test.ts | 14 ++++++++++++++ 3 files changed, 29 insertions(+), 7 deletions(-) diff --git a/portal-template/src/styles/app.css b/portal-template/src/styles/app.css index f0a62366a..cef2f8ef3 100644 --- a/portal-template/src/styles/app.css +++ b/portal-template/src/styles/app.css @@ -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'; @@ -17,7 +17,11 @@ 'Courier New', monospace; } -@source not '../../dist'; +/* 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; diff --git a/test/e2e/portal-build.test.ts b/test/e2e/portal-build.test.ts index e6e48b8ec..ea27821e4 100644 --- a/test/e2e/portal-build.test.ts +++ b/test/e2e/portal-build.test.ts @@ -514,10 +514,16 @@ const stylesheetOf = (output: DirectoryPath) => { expect(css).to.match(/--color-fd-primary:#171717/); }); - // 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', () => { + // Tailwind reads the template and the copy of the pages, and nothing else. A scan that missed the + // template stacked the SDK cards; one that took in node_modules grew this from 93 KB to 134 KB. + it('builds the stylesheet from the template and the pages alone', () => { + const css = stylesheetOf(portal.output()); + + expect(read('sdks/index.html')).to.contain('@2xl:grid-cols-2'); + expect(css).to.contain('.\\@2xl\\:grid-cols-2'); expect(read('authentication/index.html')).to.contain('class="tracking-[0.3125em]"'); - expect(stylesheetOf(portal.output())).to.contain('.tracking-\\[0\\.3125em\\]'); + expect(css).to.contain('.tracking-\\[0\\.3125em\\]'); + expect(css.length).to.be.below(100 * 1024); }); // The counterpart of the branded portal's forced mode: the same label, present here. @@ -561,8 +567,6 @@ const stylesheetOf = (output: DirectoryPath) => { expect(page).to.match(/]*class="[^"]*hidden[^"]*dark:block/); expect(page).to.match(/ { } }); + // Tailwind reads only the folders `app.css` names, so each must be one the CLI writes pages into. + it('has Tailwind scan the directories the CLI writes pages into', () => { + const stylesheet = fs.readFileSync(path.join(templateRoot, 'src/styles/app.css'), 'utf8'); + + for (const directory of [ + CONTENT_COPY_DIRECTORY_NAME, + GENERATED_DIRECTORY_NAME, + GENERATED_INCLUDES_DIRECTORY_NAME + ]) { + expect(stylesheet).to.contain(`@source '../../${directory}';`); + } + }); + // Left to itself the popover reads `window.location.href`, so a reader's query string and hash // — tracking and preview parameters among them — reach the AI vendors it links out to. it('sends the AI vendors the page address and nothing the reader carried with them', () => { From 5a67acf41e6e1330771d723efe48f7b14a1bbb63 Mon Sep 17 00:00:00 2001 From: saeedjamshaid Date: Mon, 19 Oct 2026 15:39:08 +0500 Subject: [PATCH 09/11] docs(portal): record the review's changes in the cross-drive plan Co-Authored-By: Claude Opus 5.5 --- .ai/plans/cross-drive-portal-projects.md | 129 +++++++++++++++++------ 1 file changed, 97 insertions(+), 32 deletions(-) diff --git a/.ai/plans/cross-drive-portal-projects.md b/.ai/plans/cross-drive-portal-projects.md index eeb4697fd..1a1765fac 100644 --- a/.ai/plans/cross-drive-portal-projects.md +++ b/.ai/plans/cross-drive-portal-projects.md @@ -5,8 +5,10 @@ Status: designed 2026-10-16 on `saeedjamshaid/cross-drive-portal-styles`, cut fr same day (section 4); this plan turns it into the change and its tests. Approved by the user on 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. +Implemented the same day in steps 1–4 (`c1c04efe`, `341d2f30`, `b198b89c`, `494ffaa5`) and opened +as apimatic/apimatic-cli#440 (step 5). Its review on 2026-10-19 revised D2 and D4 and added D7: a +source on a network share is refused, and `app.css` names Tailwind's sources. Section 13 records +what the branch passed. ## 1. Goal and scope @@ -15,9 +17,9 @@ on the temp directory's drive: the same stylesheet, the same images, and nothing input folder. - **In scope:** the cross-drive fallback that stages the project in `\.apimatic-build\`, the - two upstream bugs it was dodging, and the Tailwind `@source` line that only that fallback needed. -- **Out of scope:** sources on a network share (section 10), and any other change to how the - project is laid out. + two upstream bugs it was dodging, and which files Tailwind scans. +- **Out of scope:** building from a network share, which is refused up front (D7), and any other + change to how the project is laid out. - **Invariant:** on macOS, Linux and same-drive Windows, `portal generate` writes the same files as today. `portal serve` differs only in the address a page's static image loads from: `/images/logo.png` (under the base) instead of `/@fs/`. @@ -49,7 +51,7 @@ bugs: drives that returns the absolute path, which `getImportPath` still prefixes with `./` (`./D:/site/src/static/x.png`). A build only works by accident: Vite reads it as `D:site/...`, a path relative to drive D:'s current directory, and the Vite child, whose environment is - filtered, has none. Not reported upstream; still on fumadocs `main`. + filtered, has none. Reported as fuma-nama/fumadocs#3723 (2026-10-19); still on fumadocs `main`. 2. **Vite's dev server serves no raw file from another drive.** `/@fs/D:/x.png` drops the drive and is served from the working directory's drive (vitejs/vite#12816, closed as not planned in favour of #10802, open). Without the fallback, `portal serve` answers the image with its HTML @@ -63,21 +65,31 @@ bugs: - On macOS, Linux and same-drive Windows this changes nothing: they already staged there. - **D2. One template plugin, `static-images.ts`, for a page's static images.** - It works around both upstream bugs of section 2, one hook each. - - *Resolve* (build and serve): the `./D:/…` or `.///server/share/…` specifier is resolved as the - absolute path it is. + - *Resolve* (build and serve): the `./D:/…` specifier is resolved as the absolute path it is. + Until the review it took fumadocs' `.///server/share/…` too, which Vite then resolved as a + protocol-relative URL (D7). - *Load* (serve only): a plain asset import from the static directory becomes its public address (`withBasePath`). Vite serves its public directory from any drive. - **D3. The public address on every drive and OS** (the user's choice, 2026-10-16). One behaviour, so the macOS and Linux serve tests cover it. Same-drive previews stop using `/@fs/` for static images. A query such as `?raw`, or a non-asset import, stays Vite's to answer. -- **D4. `app.css` drops `@source '../../content'`.** - - The copy is inside the project, so Tailwind's own scan reads it: a page's class survived on - every build in section 4 without the line. - - The unit test that held the literal is replaced by an e2e check that a page's class reaches - the stylesheet. That is the contract the literal stood for. +- **D4. `app.css` names Tailwind's sources** (revised 2026-10-19, after review). + - `@import 'tailwindcss' source(none)`, then `@source` for `src/`, the copy of the pages, the + generated pages and the SDK docs they include: every folder detection read, less the template's + root files, which hold build code. `@source not '../../dist'` goes, since nothing named holds + `dist/`. A packaging test pins the three names to the CLI's constants. + - As first implemented, D4 dropped `@source '../../content'` and let detection find the copy. + Detection still depends on where the temp directory sits: when it is inside a git repository + that ignores it, the project root is promoted and `node_modules` is scanned. Measured on the + default fixture: 562 classes gained, 134 KB. + - Named sources gave the same stylesheet (736 classes, 93 KB) with the temp directory in its usual + place and inside an ignored folder of a repository. The one class they lose, `collapse`, came + from a comment in the template's `prerender-pages.ts`. + - The unit test that held the `@source` literal is replaced by an e2e check of what the stylesheet + holds (7.2). - **D5. Rejected:** - - *Explicit sources* (`source(none)` and globs): the `*` still hides every subfolder of `src/`. - Measured: 24 classes still lost. + - *Explicit sources* (`source(none)` and globs), while the `*` `.gitignore` existed: it hid every + subfolder of `src/`. Measured: 24 classes still lost. Adopted in D4 once that file was gone. - *Dropping the `.gitignore`*: the folder shows in `git status` for a whole `serve` session, and the user's own ignore rules then apply to the staged files. A user who ignores `.apimatic-build/` brings the over-scan back. @@ -87,6 +99,18 @@ bugs: - The static directory stays in `server.fs.allow`. A page's `?import` request for its image still goes through `/@fs/`, and Safari can send it before the page (#2287). - `COPIED_DEPENDENCIES` stays: it is about the CLI's install drive, which this does not move. +- **D7. A source on a network share is refused up front** (the user's choice, 2026-10-19, after + review). + - `portal generate` and `portal serve` stop before signing in, and say to copy the folder to a + local drive or map the share to a drive letter. `DirectoryPath.isOnNetworkShare` decides it. + - Vite's `normalizePath` turns `\\server\share` into `/server/share`. Its `publicDir` then names + no folder, and the static directory is left out of the build without a word. + - Fumadocs' `.///server/share/…` import, with the `./` taken off, is a protocol-relative URL to + Vite. The SSR bundle kept it as an import that Node cannot load, so prerendering failed. + - A share mapped to a drive letter builds like any other drive, static directory included + (checked with `net use Z: \\localhost\C$`). + - `dev` fails on a share too, earlier (EPERM creating a link in `.apimatic-build`), so the refusal + takes nothing away. ## 4. What was verified (2026-10-16, prototype) @@ -134,8 +158,9 @@ Not run: macOS (CI's job and a Safari check, section 8 step 5) and a source on a export function staticImages(staticDirectory: string | null): Plugin[] ``` - - **Resolve plugin:** `enforce: 'pre'` and a `resolveId` filter of `^\.\/(?:[A-Za-z]:\/|\/\/)`. - It answers `this.resolve(source.slice(2), importer, { ...options, skipSelf: true })`. + - **Resolve plugin:** `enforce: 'pre'` and a `resolveId` filter of `^\.\/[A-Za-z]:\/` (until + 2026-10-19 it also took `//`; see D7). It answers + `this.resolve(source.slice(2), importer, { ...options, skipSelf: true })`. - **Serve plugin** (only when there is a static directory): - `apply: 'serve'`, `enforce: 'pre'`. - It reads `base` and `assetsInclude` from the resolved config. @@ -144,11 +169,11 @@ Not run: macOS (CI's job and a Safari check, section 8 step 5) and a source on a otherwise. - The directory is matched in two spellings, as given and real (`realpathSync.native`). Vite keeps a `subst` drive's spelling and gives everything else its real path (macOS `/var` is - `/private/var`). + `/private/var`). On Windows both sides are lower-cased first (2026-10-19, after review). - Comments: one line each, per `.ai/instructions.md`; the two upstream links carry the why. 2. **`portal-template/vite.config.ts`**: `staticImages(paths.staticDir)` after `staticFunctionsBase()`. -3. **`portal-template/src/styles/app.css`**: drop the three lines of D4. +3. **`portal-template/src/styles/app.css`**: the named sources of D4. ## 7. Tests @@ -159,17 +184,22 @@ Not run: macOS (CI's job and a Safari check, section 8 step 5) and a source on a portal project too" case. - Keep "the temp directory a run is given is spelled the way the filesystem spells it". - **`test/portal-template/static-images.test.ts`** (new): - - The resolve filter takes `./D:/x.png`, `./d:/x.png` and `.///server/share/x.png`. It leaves - `./local.png`, `../x.png`, `D:/x.png` and `/images/x.png` alone. + - The resolve filter takes `./D:/x.png` and `./d:/x.png`. It leaves `./local.png`, + `.//local.png`, `../x.png`, `D:/x.png`, `/images/x.png` and `.///server/share/x.png` alone. - The handler resolves the path without its `./`, and skips itself. - The serve plugin applies to `serve` only, and there is none without a static directory. - An image under the static directory loads as `/images/x.png` at the root and as `/docs/images/x.png` under `/docs/`. This holds in its real spelling too, through a directory link made in the test. + - On Windows, it knows the directory spelled in another case. - It answers `null` for an id with a query (`?raw`), a non-asset (`.json`), and a file outside the directory. +- **Network shares (D7):** `DirectoryPath.isOnNetworkShare` holds `\\server\share\…` and + `//server/share/…`, and not `C:\…` or `\\?\C:\…`. `portal generate` and `portal serve` stop on a + share before signing in. These run on Windows only. - **`test/portal-template.test.ts:148-152`**: delete "has Tailwind scan the copy of the pages, by - its relative name"; 7.2 replaces it. + its relative name"; 7.2 replaces it. Since D4's revision, "has Tailwind scan the directories the + CLI writes pages into" checks each named folder against the CLI's constant. ### 7.2 End to end @@ -178,14 +208,18 @@ Not run: macOS (CI's job and a Safari check, section 8 step 5) and a source on a `BuiltPortal` and the clean-up. - `canonical` matters on GitHub's Windows runner, whose `%TEMP%` is the short name `C:\Users\RUNNER~1\…`. The CLI resolves it through `withDirPath`, and the tests must too. -- **Build:** "generates the utility classes a page uses". +- **Build:** "builds the stylesheet from the template and the pages alone" (first "generates the + utility classes a page uses"; widened 2026-10-19, after review). - The default fixture's `content/authentication.md` becomes `authentication.mdx`, with one element carrying an arbitrary-value class no other file uses (`tracking-[0.3125em]`). Content pages have no `rehype-raw`, so only MDX renders an author's class. No test names that page, whereas `guides/` is rewritten by the serve unit tests. - The page carries the class and the built stylesheet holds its rule. - - The template's own classes need no new case: the branded build already asserts `dark:block`, a - template-only class #2301 lost (step 3, 2026-10-16). + - So does `@2xl:grid-cols-2`, the SDK cards' grid, which only the template uses and #2301 lost. + It replaces the branded build's `dark:block` check. + - The stylesheet stays under 100 KB (93 KB today). CI's Windows runner builds the fixture inside + its git checkout, where `dev` over-scans rather than drops classes (134 KB), so this ceiling is + what catches the regression there. - **Serve:** "serves a page's static image at its public address, asked for before the page" replaces the raw-`/@fs/` case. - It asks for `/@fs//images/logo.png?import` first and gets @@ -217,7 +251,8 @@ Each step stops for review, and each commit waits to be asked for. - Before merge, someone with a Mac opens a `portal serve` page with a static image in Safari. 6. **Follow-ups**, each drafted for approval before it is posted: - a reply on apimatic-io#2301 (cause, what the comment's notes got wrong, the fix); - - a fumadocs issue for `remarkImage`'s cross-drive import path. + - a fumadocs issue for `remarkImage`'s cross-drive import path: fuma-nama/fumadocs#3723, + filed 2026-10-19. ## 9. Risks @@ -239,10 +274,20 @@ Each step stops for review, and each commit waits to be asked for. ## 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 - public directory like any other path. - - Not run: there is no share on the machine this was measured on. +- **A source on a network share** is refused (D7). Measured on `\\localhost\C$` (2026-10-19): + - with the `//` branch, prerendering a page with a static image failed + (`ERR_INVALID_FILE_URL_PATH`); + - with the image resolved directly, the build passed, but without the static directory; + - through a mapped drive letter, the build was complete. +- **A `?url` import of a static image under `serve`, across drives.** Vite answers + `export default "/@fs/D:/…"`, and that address returns the HTML page (a 403 from a `subst` + drive). Under the fallback the project shared the drive, so it worked. Pages import images + through fumadocs, without a query, so none does this today. +- **`serve` and `generate` give a static image different addresses**: `/images/x.png` in the + preview, `/assets/x-.png` in the build. Both load; only the preview's is new. +- **`.apimatic-build/` folders that earlier versions left beside `src/`** are not removed. Git + ignores them (their own `.gitignore` is `*`), so a line in the release notes saying they can be + deleted is enough. - **vitejs/vite#10802 in general.** A raw, non-JavaScript file that a dependency serves from another drive than the project would still fail under `serve`. - The template has none. Its fonts are copied (`COPIED_DEPENDENCIES`), and the rest goes through @@ -252,8 +297,9 @@ Each step stops for review, and each commit waits to be asked for. ## 11. Upstream -- **fumadocs-core `remarkImage`:** `getImportPath` should return an absolute (or `file://`) path - when `path.relative` does. The resolve hook goes once a fixed version is pinned. +- **fumadocs-core `remarkImage`** (fuma-nama/fumadocs#3723, filed 2026-10-19): `getImportPath` + should return an absolute path when `path.relative` does. The resolve hook goes once a fixed + version is pinned. - **vitejs/vite#10802:** the serve hook stays while that is open. ## 12. Delivery @@ -283,3 +329,22 @@ Each step stops for review, and each commit waits to be asked for. it is stricter on macOS than before. - PR #437 bumps fumadocs-core to 16.17.1, whose `getImportPath` is unchanged, so the resolve hook still applies. +- **2026-10-16:** opened as #440. CI passed in full: ubuntu, windows and macOS on Node 24 and 26, + SonarCloud. +- **2026-10-19:** mehnoorsiddiqui's review. + - The user chose to: + - refuse network shares (D7); + - name Tailwind's sources (D4); + - merge `dev` in rather than rebase; + - file fuma-nama/fumadocs#3723. + - Also from the review: + - the e2e ceiling and template class (7.2); + - the case-insensitive match (6.1); + - the `?url` limitation (section 10); + - SonarCloud's nested template literal. + - The branch passed: + - CI's job on Windows, with `dev` merged in: 1924 passing, lint clean; + - the e2e suites from a `subst` drive: 69/69; + - `portal serve` through a mapped drive (`Z:` → `\\localhost\C$`): images from `/images/`, the + logo and the full stylesheet; + - both commands on `\\localhost\C$\…`: refused before signing in, with nothing written. From 7b39678294eca6da4c3f3a69dd6691dbc9ec42eb Mon Sep 17 00:00:00 2001 From: saeedjamshaid Date: Mon, 19 Oct 2026 15:47:02 +0500 Subject: [PATCH 10/11] test(portal): check the network-share refusal in the shared preparation'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 --- test/actions/portal/generate.test.ts | 11 ------ test/actions/portal/prepare-project.test.ts | 37 +++++++++++++++++++++ test/actions/portal/serve.test.ts | 11 ------ 3 files changed, 37 insertions(+), 22 deletions(-) create mode 100644 test/actions/portal/prepare-project.test.ts diff --git a/test/actions/portal/generate.test.ts b/test/actions/portal/generate.test.ts index 9d8a6688c..c1e5f15d3 100644 --- a/test/actions/portal/generate.test.ts +++ b/test/actions/portal/generate.test.ts @@ -150,17 +150,6 @@ describe('GenerateAction', () => { expect(authorize.called).to.be.false; }); - it('stops before signing in when the source is on a network share', async function () { - if (process.platform !== 'win32') this.skip(); - const share = new DirectoryPath('\\\\server\\share\\api'); - - const result = await execute(share); - - expect(result.isFailed()).to.be.true; - expect(shared.prompts.networkShareUnsupported.calledOnceWith(share.join('src'))).to.be.true; - expect(authorize.called).to.be.false; - }); - it('fails when the account may not build a portal, without starting a build', async () => { authorize.resolves(err({ kind: 'notEntitled' as const })); diff --git a/test/actions/portal/prepare-project.test.ts b/test/actions/portal/prepare-project.test.ts new file mode 100644 index 000000000..912fab47b --- /dev/null +++ b/test/actions/portal/prepare-project.test.ts @@ -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; + }); +}); diff --git a/test/actions/portal/serve.test.ts b/test/actions/portal/serve.test.ts index e3b8def04..f29e5d5c8 100644 --- a/test/actions/portal/serve.test.ts +++ b/test/actions/portal/serve.test.ts @@ -103,17 +103,6 @@ describe('PortalServeAction', () => { expect(authorize.called).to.be.false; }); - it('stops before signing in when the source is on a network share', async function () { - if (process.platform !== 'win32') this.skip(); - const share = new DirectoryPath('\\\\server\\share\\api'); - - const result = await execute(share); - - expect(result.isFailed()).to.be.true; - expect(shared.prompts.networkShareUnsupported.calledOnceWith(share.join('src'))).to.be.true; - expect(authorize.called).to.be.false; - }); - it('fails when the account may not build a portal, without starting the preview', async () => { authorize.resolves(err({ kind: 'notEntitled' as const })); From 76949da110fc5280a7b08c280649f9d7f449c929 Mon Sep 17 00:00:00 2001 From: saeedjamshaid Date: Mon, 19 Oct 2026 15:47:09 +0500 Subject: [PATCH 11/11] docs(portal): defer the Safari check to QA in the cross-drive plan Co-Authored-By: Claude Opus 5.5 --- .ai/plans/cross-drive-portal-projects.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/.ai/plans/cross-drive-portal-projects.md b/.ai/plans/cross-drive-portal-projects.md index 1a1765fac..2b23064ec 100644 --- a/.ai/plans/cross-drive-portal-projects.md +++ b/.ai/plans/cross-drive-portal-projects.md @@ -195,8 +195,9 @@ Not run: macOS (CI's job and a Safari check, section 8 step 5) and a source on a - It answers `null` for an id with a query (`?raw`), a non-asset (`.json`), and a file outside the directory. - **Network shares (D7):** `DirectoryPath.isOnNetworkShare` holds `\\server\share\…` and - `//server/share/…`, and not `C:\…` or `\\?\C:\…`. `portal generate` and `portal serve` stop on a - share before signing in. These run on Windows only. + `//server/share/…`, and not `C:\…` or `\\?\C:\…`. `PreparePortalProjectAction`, which both + commands run, stops on a share before signing in; its test is the action's own, since a copy in + each command's tests duplicated their neighbours (SonarCloud's gate). These run on Windows only. - **`test/portal-template.test.ts:148-152`**: delete "has Tailwind scan the copy of the pages, by its relative name"; 7.2 replaces it. Since D4's revision, "has Tailwind scan the directories the CLI writes pages into" checks each named folder against the CLI's constant. @@ -249,6 +250,8 @@ Each step stops for review, and each commit waits to be asked for. 5. **Pull request into `dev`:** - CI's matrix (ubuntu, windows, macos × Node 24, 26) and SonarCloud. - Before merge, someone with a Mac opens a `portal serve` page with a static image in Safari. + Deferred on 2026-10-19 (the user's choice: no one on the PR has a Mac) to QA's pass of the + `dev` build after merge; the steps are on apimatic-io#2301. 6. **Follow-ups**, each drafted for approval before it is posted: - a reply on apimatic-io#2301 (cause, what the comment's notes got wrong, the fix); - a fumadocs issue for `remarkImage`'s cross-drive import path: fuma-nama/fumadocs#3723, @@ -348,3 +351,7 @@ Each step stops for review, and each commit waits to be asked for. - `portal serve` through a mapped drive (`Z:` → `\\localhost\C$`): images from `/images/`, the logo and the full stylesheet; - both commands on `\\localhost\C$\…`: refused before signing in, with nothing written. + - SonarCloud's gate then failed on duplicated lines (8.5% of new code): the share test, copied + into both commands' tests, joined their neighbours into one 34-line block. It moved to a test + of the shared action (7.1). + - The Safari check went to QA, after merge (section 8, step 5).