diff --git a/.ai/plans/cross-drive-portal-projects.md b/.ai/plans/cross-drive-portal-projects.md new file mode 100644 index 000000000..2b23064ec --- /dev/null +++ b/.ai/plans/cross-drive-portal-projects.md @@ -0,0 +1,357 @@ +# 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. + +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 + +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 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/`. + +## 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. 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 + 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:/…` 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` 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), 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. + - *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. +- **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) + +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]:\/` (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. + - `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`). 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`**: the named sources 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` 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:\…`. `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. + +### 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:** "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. + - 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 + `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, 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. + - 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. + 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, + filed 2026-10-19. + +## 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** 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 + 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`** (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 + +- **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. + - 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. +- **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. + - 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). 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 diff --git a/portal-template/src/styles/app.css b/portal-template/src/styles/app.css index 6770b3918..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,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; diff --git a/portal-template/static-images.ts b/portal-template/static-images.ts new file mode 100644 index 000000000..4e244c97a --- /dev/null +++ b/portal-template/static-images.ts @@ -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 }); + } + } + }; + 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. + 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)};`; + } + } + ]; +} 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/src/actions/portal/prepare-project.ts b/src/actions/portal/prepare-project.ts index 9f2f16ea4..fe380f6b5 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'; @@ -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, @@ -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); 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/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/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/e2e/portal-build.test.ts b/test/e2e/portal-build.test.ts index fb785eac9..ea27821e4 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, @@ -522,6 +514,18 @@ const stylesheetOf = (output: DirectoryPath) => { expect(css).to.match(/--color-fd-primary:#171717/); }); + // 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(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. it('offers the colour-mode switch when both modes are allowed', () => { expect(read('index.html')).to.contain('aria-label="Toggle Theme"'); @@ -563,8 +567,6 @@ const stylesheetOf = (output: DirectoryPath) => { expect(page).to.match(/]*class="[^"]*hidden[^"]*dark:block/); expect(page).to.match(/ { ({ 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 }); - } - ); }); diff --git a/test/portal-template.test.ts b/test/portal-template.test.ts index a7b5b286f..83d5799e2 100644 --- a/test/portal-template.test.ts +++ b/test/portal-template.test.ts @@ -11,6 +11,7 @@ import { TEMPLATE_DEPENDENCIES } from '../src/infrastructure/portal-project-service'; import { GENERATED_DIRECTORY_NAME, PAGE_TEMPLATES } from '../src/types/portal/generated-pages'; +import { GENERATED_INCLUDES_DIRECTORY_NAME } from '../src/types/portal/page-fragments'; import { PortalIdentity } from '../src/types/portal/portal-config'; import type { BuildPaths } from '../portal-template/portal-config'; import type { Portal } from '../portal-template/src/lib/portal-types'; @@ -145,11 +146,17 @@ 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', () => { + // 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'); - expect(stylesheet).to.contain(`@source '../../${CONTENT_COPY_DIRECTORY_NAME}';`); + 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 diff --git a/test/portal-template/static-images.test.ts b/test/portal-template/static-images.test.ts new file mode 100644 index 000000000..557cf64d5 --- /dev/null +++ b/test/portal-template/static-images.test.ts @@ -0,0 +1,110 @@ +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']) { + 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 }] + ]); + }); + + // 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', + './/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; + } + }); + + 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('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); + + 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; + }); +}); 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.

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