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/.ai/plans/portal-subpath-hosting.md b/.ai/plans/portal-subpath-hosting.md index ffb672f74..4424ce033 100644 --- a/.ai/plans/portal-subpath-hosting.md +++ b/.ai/plans/portal-subpath-hosting.md @@ -528,7 +528,7 @@ follow-up. `publicDir`, an SDK page's root-relative image would fail the build once imported (measured). - `src/types/portal/page.ts` checks those images before a build too. A missing one is then reported by the CLI, not by a failed build. A contract test holds the two to the same images. - - Like every imported image, it now reads `` in the Markdown copies (section 9). + - The Markdown copies keep the reference as written, since fumadocs-core 16.17 (section 9). ### 5.11 The query on load (`lib/search-options.ts`, added 2026-10-07) @@ -741,8 +741,6 @@ after each one. In all, roughly 400 lines of source and 500 of tests. - It names `robots.txt` (and `sitemap.xml`) even where none is generated: without `site.url`, and now under a path. `GENERATED_ROOT_FILES` lists them unconditionally. - Pre-existing, so not fixed here. - - a ticket for the `` placeholders in the Markdown copies (section 9), - found while checking the links there; - the optional TanStack reaction or comment of section 10, if the user wants one. The Fumadocs issue was filed by the user as fuma-nama/fumadocs#3620 and fixed in fumadocs-ui 16.15.17 (section 10). @@ -809,20 +807,22 @@ after each one. In all, roughly 400 lines of source and 500 of tests. Markdown internals. - The cost is that content hard-codes the portal's address. - **Alternative: rewrite the copies' links.** - - Fumadocs' `remarkLLMs` takes mdast-to-markdown `handlers` through a collection's - `includeProcessedMarkdown` options. Handlers for `link`, `image` and `definition` would - prefix root-relative URLs in the copy alone. - - Open: how the handlers learn the base, given that `source.ts` goes through + - Since fumadocs-core 16.17, `remarkLLMs` slices the copy from the authored source and takes + no `handlers` or `stringify` (16.15.17 took both). A plugin changes the copy with + `replaceSource()` from `fumadocs-core/mdx-plugins/stringifier`. + - A remark plugin that calls `replaceSource()` on root-relative `link`, `image` and + `definition` nodes would prefix their URLs in the copy alone. + - Open: how the plugin learns the base, given that `source.ts` goes through `fumadocs-mdx/macro`. -**Images in the Markdown copies are broken on every portal** (measured 2026-09-30, root and -path alike). `remarkImage` swaps the image for an import, and the copy and `llms-full.txt` carry -`…`, which leads nowhere. This predates the path: `useImport` is the -default. It gets a ticket of its own. Since 5.10, reference-style images read the same way. -- It is Fumadocs' bug, filed as fuma-nama/fumadocs#3662 (section 10). -- A workaround is possible: plugins on either side of `remarkImage`, plus the `stringify` hook - that `includeProcessedMarkdown` passes on. It is held back, because it would rest on - `remarkImage`'s internal `__img${n}` names. +**Root-relative images stay unprefixed in the Markdown copies**, as links do. +- An image written `/images/diagram.png` is under the base in the page's HTML, but keeps its + spelling in the `.md` copy and in `llms-full.txt`, inline and by reference alike. +- The `replaceSource()` plugin of the alternative above would prefix them along with the links. +- Until fumadocs-core 16.17, the copies carried `…` instead, on every + portal (measured 2026-09-30, root and path alike). That was Fumadocs' bug, fuma-nama/fumadocs#3662, + fixed by the upgrade of 2026-10-16 (section 10). The subpath e2e checks that the copies keep the + images as written. **API reference pages' Markdown copies keep the spec's own text:** its description and the YAML. A root-relative link inside a spec description is prefixed in the page's HTML, which renders it @@ -837,7 +837,7 @@ with the MDX components, but not in its copy. | TanStack/router#4888, docs PR #7882 | Docs only | None | | TanStack/router#8448, PR #8450 | Both open (checked 2026-10-07); the PR has no review yet. The issue reports a second loader run; we saw the address rewritten too | When it ships, `searchOptions` can go (5.11) | | fuma-nama/fumadocs#3661 | Filed 2026-10-07: an image in an operation's description crashes the page | When it ships with the base, `ApiMarkdown` can go (5.10) | -| fuma-nama/fumadocs#3662 | Filed 2026-10-07: processed Markdown prints images as `__img0` | When it ships, ticket 5 is fixed by the upgrade (section 9) | +| fuma-nama/fumadocs#3662 | Fixed 2026-10-09 in fumadocs-core 16.17.0 and fumadocs-mdx 15.4.7, installed since 2026-10-16 (core 16.17.1): the processed Markdown is sliced from the authored source, so images keep their addresses | The upgrade fixes it, so it needs no ticket; root-relative images stay unprefixed (section 9). The subpath e2e checks the copies | **The fixes:** - **Fumadocs:** filed by the user as fuma-nama/fumadocs#3620, from a draft made here, and fixed diff --git a/.ai/skills/value-object.md b/.ai/skills/value-object.md index db7397c7a..e5b297858 100644 --- a/.ai/skills/value-object.md +++ b/.ai/skills/value-object.md @@ -122,7 +122,7 @@ These patterns exist in the current codebase and are flagged for awareness: |---|---| | Ideal simple wrapper — private field, `static create()`, `toString()` only | `src/types/file/urlPath.ts` | | `static createInput()` fallback, rich join/equality methods | `src/types/file/directoryPath.ts` | -| Rich predicate (`isMarkDown`) and transform (`normalize`) returning rich type | `src/types/file/fileName.ts` | +| Rich predicate (`hasExtension`) and transform (`normalize`) returning rich type | `src/types/file/fileName.ts` | | Composed value object — holds two rich types, `static create()` splits at boundary | `src/types/file/filePath.ts` | | Correct boundary unwrapping — all `.toString()` calls at `fs.*` / `path.*` sites | `src/infrastructure/file-service.ts` | | Context with flagged public getter (display use) | `src/types/sdk-context.ts` | diff --git a/README.md b/README.md index f04fca6e0..063fd949b 100644 --- a/README.md +++ b/README.md @@ -349,8 +349,8 @@ DESCRIPTION Preview your API Documentation Portal with live reload. Serves the portal described by 'src/apimatic.json' from your machine, reloading the browser as you edit the Markdown - pages in 'src/content', reorder them in a 'nav.json', or change the 'portal', 'languages' or 'plugin' block of - 'apimatic.json'. + pages in 'src/content', reorder them in a 'nav.json', edit the documents in 'src/spec', or change the 'portal', + 'languages' or 'plugin' block of 'apimatic.json'. Adding a language or a 'plugin' block, adding or removing a page in 'src/content', creating 'src/static', changing which documents are in 'src/spec' or which folders its 'APIMATIC-META.json' merges, or changing the path in diff --git a/package.json b/package.json index 6338f58eb..69aef9d9e 100644 --- a/package.json +++ b/package.json @@ -77,14 +77,13 @@ "fast-levenshtein": "^3.0.0", "form-data": "^4.0.2", "fs-extra": "^11.3.0", - "fumadocs-core": "16.15.17", - "fumadocs-mdx": "15.4.0", + "fumadocs-core": "16.17.1", + "fumadocs-mdx": "15.4.7", "fumadocs-openapi": "12.1.1", - "fumadocs-ui": "16.15.17", + "fumadocs-ui": "16.17.1", "get-port": "^7.1.0", "is-in-ci": "^2.0.0", - "isomorphic-git": "^1.37.2", - "lucide-react": "1.47.0", + "lucide-react": "1.54.0", "mustache": "^4.2.0", "neverthrow": "^8.2.0", "open": "^8.4.0", @@ -96,7 +95,7 @@ "remark": "15.0.1", "remark-mdx": "3.1.1", "semver": "^7.8.0", - "shiki": "4.4.3", + "shiki": "4.5.0", "striptags": "^3.2.0", "tailwindcss": "4.3.3", "tmp-promise": "^3.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 61cda5135..5a6c76e36 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,7 +39,7 @@ importers: version: 5.3.0 '@fumadocs/api-docs': specifier: 0.2.8 - version: 0.2.8(@types/react@19.3.0)(fumadocs-core@16.15.17(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.47.0(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.5.4))(fumadocs-ui@16.15.17(@types/mdx@2.0.14)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(fumadocs-core@16.15.17(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.47.0(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.5.4))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3))(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 0.2.8(@types/react@19.3.0)(fumadocs-core@16.17.1(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.54.0(@types/react@19.3.0)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.5))(fumadocs-ui@16.17.1(@types/mdx@2.0.14)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(fumadocs-core@16.17.1(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.54.0(@types/react@19.3.0)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.5))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3))(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@oclif/core': specifier: 4.11.0 version: 4.11.0 @@ -92,29 +92,26 @@ importers: specifier: ^11.3.0 version: 11.3.5 fumadocs-core: - specifier: 16.15.17 - version: 16.15.17(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.47.0(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.5.4) + specifier: 16.17.1 + version: 16.17.1(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.54.0(@types/react@19.3.0)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.5) fumadocs-mdx: - specifier: 15.4.0 - version: 15.4.0(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.3.0)(fumadocs-core@16.15.17(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.47.0(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.5.4))(react@19.2.8)(rolldown@1.2.8)(vite@8.2.2(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.1)) + specifier: 15.4.7 + version: 15.4.7(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.3.0)(fumadocs-core@16.17.1(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.54.0(@types/react@19.3.0)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.5))(react@19.2.8)(rolldown@1.2.8)(vite@8.2.2(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.1)) fumadocs-openapi: specifier: 12.1.1 - version: 12.1.1(@types/react@19.3.0)(fumadocs-core@16.15.17(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.47.0(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.5.4))(fumadocs-ui@16.15.17(@types/mdx@2.0.14)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(fumadocs-core@16.15.17(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.47.0(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.5.4))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3))(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 12.1.1(@types/react@19.3.0)(fumadocs-core@16.17.1(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.54.0(@types/react@19.3.0)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.5))(fumadocs-ui@16.17.1(@types/mdx@2.0.14)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(fumadocs-core@16.17.1(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.54.0(@types/react@19.3.0)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.5))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3))(react-dom@19.2.8(react@19.2.8))(react@19.2.8) fumadocs-ui: - specifier: 16.15.17 - version: 16.15.17(@types/mdx@2.0.14)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(fumadocs-core@16.15.17(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.47.0(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.5.4))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3) + specifier: 16.17.1 + version: 16.17.1(@types/mdx@2.0.14)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(fumadocs-core@16.17.1(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.54.0(@types/react@19.3.0)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.5))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3) get-port: specifier: ^7.1.0 version: 7.2.0 is-in-ci: specifier: ^2.0.0 version: 2.0.0 - isomorphic-git: - specifier: ^1.37.2 - version: 1.37.6 lucide-react: - specifier: 1.47.0 - version: 1.47.0(react@19.2.8) + specifier: 1.54.0 + version: 1.54.0(@types/react@19.3.0)(react@19.2.8) mustache: specifier: ^4.2.0 version: 4.2.0 @@ -149,8 +146,8 @@ importers: specifier: ^7.7.0 version: 7.8.0 shiki: - specifier: 4.4.3 - version: 4.4.3 + specifier: 4.5.0 + version: 4.5.0 striptags: specifier: ^3.2.0 version: 3.2.0 @@ -1133,8 +1130,8 @@ packages: react: optional: true - '@fumadocs/tailwind@0.1.2': - resolution: {integrity: sha512-VppeXgIRXjZMgpf2qijj1hcfxyeYl1s0ok8k6N3jyjiX64M7sDwnt2zGPd6dbfDybgbBvDw6C6J1e42gB5NzsA==} + '@fumadocs/tailwind@0.1.3': + resolution: {integrity: sha512-gDgLbTnY5b+nogCg2y/pP7GsaQS4Zt/0vftJAu07Wy7Wsb3/lwd9GwoGqccCnPElTFwMG+pPwrmHXBPexEfE8A==} peerDependencies: tailwindcss: ^4.0.0 peerDependenciesMeta: @@ -1368,8 +1365,8 @@ packages: resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} - '@jridgewell/sourcemap-codec@1.5.5': - resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} @@ -2023,32 +2020,32 @@ packages: peerDependencies: semantic-release: '>=20.1.0' - '@shikijs/core@4.4.3': - resolution: {integrity: sha512-QCR4q2ZO/ILJEuwiBMel4wdcTDb1JGwfjKTxPDF6x8ixOaluPrVqIn06C99AcRPhmYlBR56d/Fb+GN58GzExpg==} + '@shikijs/core@4.5.0': + resolution: {integrity: sha512-6CIgwq+bt2JZ3OSjb/nBkkwEf/CfpHcbam+Mg5Pllco7HAGRQ2/EFhZrrJtbyNTgKwknfVS+llsXGygiDGwJWw==} engines: {node: '>=20'} - '@shikijs/engine-javascript@4.4.3': - resolution: {integrity: sha512-FbOjFJp9VLdo1Wevs10BBtVxiTWwNLqZh5Gkhjgda/ioL15YOgeSl9n+6XMa3qRlPQzfhFNe641SrynFHYG0nQ==} + '@shikijs/engine-javascript@4.5.0': + resolution: {integrity: sha512-Ed6UdEj84LyYkwnxM5CvSMWAD+id9ExTYri0/+x41U9zrgNifPSS9lZm0JqBP0VfYI8yr8tY5nbNq9+p9R0kFA==} engines: {node: '>=20'} - '@shikijs/engine-oniguruma@4.4.3': - resolution: {integrity: sha512-EcOQkxdxGQrc1Row/cC2c96/v1dbZqGnEVu1qTuT/MJmp6+cXCvQussowVmCv5Tqr3KuY3c7IbM6HTW3LJ1k9w==} + '@shikijs/engine-oniguruma@4.5.0': + resolution: {integrity: sha512-dKVmbJecVdB9recl8a/CU4jdpNXyjJQrIcesIkpS70s5HS02ohpnCSpMUbotPnEUzbAcLQ+S4hwZYbkBBiTcJw==} engines: {node: '>=20'} - '@shikijs/langs@4.4.3': - resolution: {integrity: sha512-ePic0yfAJGOF83D5wBHK/00EjK65oahBYxFk5epgq33WRv7X9UuxLEV8PtR0szC0z8dl7INIpIodB99JRFlR+A==} + '@shikijs/langs@4.5.0': + resolution: {integrity: sha512-wwBTqwduXSz0X4v9Qa2EWeFaYJEw1clCncWjoRNQRbVDu3SPRTRWucnouYapKj3b8Q+sk/41Vbc4B5c8nuYzLA==} engines: {node: '>=20'} - '@shikijs/primitive@4.4.3': - resolution: {integrity: sha512-m0wBeLDQDeIxRdUmrCPdQqfuUamDwRL5isCfYbguKD6NiaKpVbsv+3J81DyIKgNW5h4WAIIr8T4EkgQrBBxvaQ==} + '@shikijs/primitive@4.5.0': + resolution: {integrity: sha512-UM7ShlA8oz0OWxLLO8BmO9Ip6fVqdkt8yF0EH7nOCqHfrx1eEAzF0ZUVK7L+I8NEgeu1PEWSRlxaiPPeCb4iDg==} engines: {node: '>=20'} - '@shikijs/themes@4.4.3': - resolution: {integrity: sha512-w8UHjeUnIR965KMWJHUPXOc2mNJUnK3vpVLYLvw5IYU2mnTTJ89E24OrJDBNiJDQ0qzb0tc4l7mrIXx5cFeIyw==} + '@shikijs/themes@4.5.0': + resolution: {integrity: sha512-zGb87H3UBbpuI4yoh5UguB1XbU+2BDU3kflM7UfZ+Cy2hWo9lBtqjSVK20gmLY6ukYl040hAypGXMgud9vul8Q==} engines: {node: '>=20'} - '@shikijs/types@4.4.3': - resolution: {integrity: sha512-UEJxmRR++MAGR6hugn0vgVS2W/6lWAts84FFSrnlH9sP0LNol7E5+NQ792pH8liWUhyMyjhTgSUH3k7iD7tc5g==} + '@shikijs/types@4.5.0': + resolution: {integrity: sha512-drtPrtD8N0DENyD2cyt6Ajy+IWsiCLgMxODwLTpvH1UsN6a9jf90tNTPjqwHvA+0Lzu9e0txk8DjfqH1p9UJRw==} engines: {node: '>=20'} '@shikijs/vscode-textmate@10.0.2': @@ -2854,78 +2851,74 @@ packages: oxc-transform-react: optional: true - '@yuku-analyzer/binding-android-arm64@0.9.5': - resolution: {integrity: sha512-Rvr0/EkPfPgc0C1wx8dz4bv6o4I4yeYI06tcHUL0f4ZdwSyIreABoLHNW51Ap0VNA7aDRVQ4izRNQh/LuykofA==} + '@yuku-core/binding-android-arm64@0.17.0': + resolution: {integrity: sha512-Uge3U7s9vdCcYb9IiIWBIQmGEttlvWkvWYb9Qu6iUNS2IhAu4/D/g4ucbEmjemkflWYGpWsKZlX+JxcacIlR+g==} cpu: [arm64] os: [android] - '@yuku-analyzer/binding-darwin-arm64@0.9.5': - resolution: {integrity: sha512-sh9DaXzkVmedM9aWvjCAAluRtvz/trk5VSosF7dPhqDf3IhrJW2DOh9iuA+YQzWSktP/ILsKfKili2UZ/ZjRzw==} + '@yuku-core/binding-darwin-arm64@0.17.0': + resolution: {integrity: sha512-jpWhRhfsAFUKXR/dUFoA9NgHeeIl5Yn0hmBEDt4QUEmnYuYZSDXeKQeSCazvIvfzbI0lyIkFSf5/TQeRPwiNuA==} cpu: [arm64] os: [darwin] - '@yuku-analyzer/binding-darwin-x64@0.9.5': - resolution: {integrity: sha512-qPKyWlGK92vjwwFdAKwGkfqF3rye4UR8X2WPvzhOSt/plATd2pPJFyMBhF1aclRMBNbk20YnctWYRYLS8FBgAg==} + '@yuku-core/binding-darwin-x64@0.17.0': + resolution: {integrity: sha512-AoWoVw8rktzCHXvpoygUu2QZ37LVlCp77p0oZ8EijCMUIaWzTMNUnkRGosEGvzXJOOpzB4wuXYVkphcV55Er+g==} cpu: [x64] os: [darwin] - '@yuku-analyzer/binding-freebsd-x64@0.9.5': - resolution: {integrity: sha512-LeSbxDQ7k0WFyddM3BiFW3i55eSkWwi4hgG8PmqNBGL9dZ4sysYOwcy3We8vyxc/fyhuhc0CYxa6p6v14+UBow==} + '@yuku-core/binding-freebsd-x64@0.17.0': + resolution: {integrity: sha512-CpyBhwZSzkRX6ZPt3AYVlG0TYk11cTP6rZ7Rz0Ux1BLxHgy4TmEW4UGsiKnL5FMAY/nM3HudevWmvaUVV4Qgng==} cpu: [x64] os: [freebsd] - '@yuku-analyzer/binding-linux-arm-gnu@0.9.5': - resolution: {integrity: sha512-GVtm5UmUhKMnicVz/Q4QgRr7OFyvxDrr7j9u0Mrgnisj/GmABhcFhzqVwW+rbS+EFnUHY7yIamwY2Fc3CoCTCw==} + '@yuku-core/binding-linux-arm-gnu@0.17.0': + resolution: {integrity: sha512-H6SQK917PjtWpbQmqUsRAqDDToutDqoXWzLKKAFY+6PSfNnzRPLrydm6SG/GkHT91L8Z2/yJPwXzzF98t1UlbQ==} cpu: [arm] os: [linux] libc: [glibc] - '@yuku-analyzer/binding-linux-arm-musl@0.9.5': - resolution: {integrity: sha512-c/9lCWXRwp0eaAvbTZslrplIjaLhiFCdc5bF/hCV13HrYYyICSwEcCgx3/OEEpwfHpdQsqejhsntUKc/0Ylbfg==} + '@yuku-core/binding-linux-arm-musl@0.17.0': + resolution: {integrity: sha512-sAxyv9p/3+fxJgXcUsaJ+8IRQWvZ4F264Gx7Kj/VBuIQX95Ih7jP/zUZPGr2UM/3NHFnXIb74Rhyuk6WLufIrQ==} cpu: [arm] os: [linux] libc: [musl] - '@yuku-analyzer/binding-linux-arm64-gnu@0.9.5': - resolution: {integrity: sha512-BwZYDPfnuP9gh8KphdjClzN3XRV0smbc2nNGkxvSOEYB/uRW0e+S2Jh82QKMSCK5TptQfovmFWe55Z5lToLAfA==} + '@yuku-core/binding-linux-arm64-gnu@0.17.0': + resolution: {integrity: sha512-hDcx5Nt04jMwkllwZVtxiWt1qQI5gCqaFcT1JLP6Lw9eOqx1Yt8p21NuqZuqknmnW5napB3dSwF7Op+eySHdrQ==} cpu: [arm64] os: [linux] libc: [glibc] - '@yuku-analyzer/binding-linux-arm64-musl@0.9.5': - resolution: {integrity: sha512-xglOsssSkPQGwendJdWud5maVWoM6h5SwlouStfg0eUACjxV8uLvnub2NT+euQJQMVzH3aRc5RuPPqzvMFeDTA==} + '@yuku-core/binding-linux-arm64-musl@0.17.0': + resolution: {integrity: sha512-SGWvwtPe3uXVGjpKk6LTjO4ieGzad/a5h3cbSeduSIiNDfotM200kifB5cPUfg2LhfbM4uCImsApYZOqXW8lkw==} cpu: [arm64] os: [linux] libc: [musl] - '@yuku-analyzer/binding-linux-x64-gnu@0.9.5': - resolution: {integrity: sha512-X7J/LWSnNsyx1E4hv6CFofXQft4GMePfc4pDn00B4lG6BO3HNR/zGlYZ5ayQsxhRNLxtAlvSFSaguaSuyz6p6g==} + '@yuku-core/binding-linux-x64-gnu@0.17.0': + resolution: {integrity: sha512-Vq258yq8+UZQwdD26b6lz9FtqwHwOVWyjy+lcbYKlAhFuN/0FNO2UzlkG5F/0pXR0iRK/Xyxkq5IZpLJHS+2HQ==} cpu: [x64] os: [linux] libc: [glibc] - '@yuku-analyzer/binding-linux-x64-musl@0.9.5': - resolution: {integrity: sha512-gmi9vazzSs4PLpHjgkFLPGCDMzG7ths3mcip0qZEHhLwLJthH5PTA+LYKi+7FRKHNQT4A50RTVox5CjdUC+prw==} + '@yuku-core/binding-linux-x64-musl@0.17.0': + resolution: {integrity: sha512-luEtfpzTP3Xph+HHz2rIVGH1VLbext5SFYrUyhKlbgoahkZ8MDzKI9TYsOVt0HukQkyEtW7+fG7yXJcdKqD4GA==} cpu: [x64] os: [linux] libc: [musl] - '@yuku-analyzer/binding-win32-arm64@0.9.5': - resolution: {integrity: sha512-0iKC5sGOCrFqumzSHLv2qCQa0OTytK605dSJrdHah5CoLT13oStEfk3jwNltYB77J3BiSagg896d5YmTY8HQPA==} + '@yuku-core/binding-win32-arm64@0.17.0': + resolution: {integrity: sha512-CaqorOjCPJgUoDwS5nztgeZW4ay21enm3A4A42EY/CotiyDbFO7lKfrqTftGb+ZXrwJ41gNtY1gQ0AaWm0Wvwg==} cpu: [arm64] os: [win32] - '@yuku-analyzer/binding-win32-x64@0.9.5': - resolution: {integrity: sha512-dSb5o6D9JugPmKRsjVLDLpgHRdSd1oCvNhY6AP6+OCzMwn0kbiNxhf2DwXYtt4xIBjliYQnw+ZsvIy1d0lHtSg==} + '@yuku-core/binding-win32-x64@0.17.0': + resolution: {integrity: sha512-gPDXHz0dPwvgj1GO7xzK8jPktrCh9xyMXZKmwqq+5bemxrSXSDUK/osSw93xmHha/PQP2WcJ1WiJ8+XEx+ciHg==} cpu: [x64] os: [win32] - '@yuku-toolchain/types@0.9.5': - resolution: {integrity: sha512-KiuLNNgX9uNealaWAR+G3/cMXnRk9x4TY2EkYe/KIag+UPdwiA0RRf1hr1WAxzTP8KGzCTkqUdLPvqh32sEO3w==} - - abort-controller@3.0.0: - resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} - engines: {node: '>=6.5'} + '@yuku-toolchain/types@0.17.0': + resolution: {integrity: sha512-3T4eKUuFAtuwbyRc2OIpanxPTl4CZdbmOvg1HB9UmRrPb3bsF3fFfbLSyIFYtZrE9/cNqLyEq/7g3Hm6nHIekA==} acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} @@ -3068,9 +3061,6 @@ packages: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} - async-lock@1.4.1: - resolution: {integrity: sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==} - async-mutex@0.5.0: resolution: {integrity: sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==} @@ -3103,9 +3093,6 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} - base64-js@1.5.1: - resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - before-after-hook@4.0.0: resolution: {integrity: sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==} @@ -3141,9 +3128,6 @@ packages: resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==} engines: {node: '>=8.0.0'} - buffer@6.0.3: - resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} - cacheable-lookup@7.0.0: resolution: {integrity: sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w==} engines: {node: '>=14.16'} @@ -3244,9 +3228,6 @@ packages: class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} - clean-git-ref@2.0.1: - resolution: {integrity: sha512-bLSptAy2P0s6hU4PzuIMKmMJJSE6gLXGH1cntDu7bWJUksvuM+7ReOK61mozULErYvP6a15rnYl0zFDef+pyPw==} - clean-stack@2.2.0: resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} engines: {node: '>=6'} @@ -3312,6 +3293,11 @@ packages: engines: {node: '>=20'} hasBin: true + cn@0.4.0: + resolution: {integrity: sha512-qOAeUhPwPzBxx6jaeQdo646XIKliKHc2j1ZF7Ngb1o0pJZKH5gq8FkA0aVdUdPFUDXNXyUCXpCtoJISo6dhizg==} + engines: {node: '>=20'} + hasBin: true + collapse-white-space@2.1.0: resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==} @@ -3430,11 +3416,6 @@ packages: typescript: optional: true - crc-32@1.2.2: - resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} - engines: {node: '>=0.8'} - hasBin: true - cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -3553,9 +3534,6 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} - diff3@0.0.3: - resolution: {integrity: sha512-iSq8ngPOt0K53A6eVr4d5Kn6GNrM2nQZtC740pzIriHtn4pOQ2lyzEXQMBeVcWERN0ye7fhBsk9PbLLQOnUx/g==} - diff@8.0.4: resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} engines: {node: '>=0.3.1'} @@ -3808,14 +3786,6 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} - event-target-shim@5.0.1: - resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} - engines: {node: '>=6'} - - events@3.3.0: - resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} - engines: {node: '>=0.8.x'} - execa@5.1.1: resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} engines: {node: '>=10'} @@ -3958,8 +3928,8 @@ packages: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} - framer-motion@13.4.4: - resolution: {integrity: sha512-lbsZO95NGbulo6apz70zEt6Vxf/anoRwQI2ECEc/EQYWO5CGbITtl7plDW8P/VXgXw6e8wrbhH4a9i+ygke0Mg==} + framer-motion@14.1.0: + resolution: {integrity: sha512-QsdZSdWoyQfUmgi+GZfow8Du5COG6VRYEo9FlgKUPgH87/Le/fYekHCAKwLdYKHdCZcZfWHg4iOxlD6RoqXciQ==} peerDependencies: react: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0 @@ -3985,8 +3955,8 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] - fumadocs-core@16.15.17: - resolution: {integrity: sha512-Pexn60R+S5F33UccW9WoLN+LHAOIraA/VvQt1xAXmfLxitx5rIPR7B9M+5mrqECnuSPltoX843NZ//Z5j/HAOg==} + fumadocs-core@16.17.1: + resolution: {integrity: sha512-zquUMPZaCEdsxFZ6ReZPzVN2yCP9tS3GZRndK0WtxbsvZ9KvvUDCeoLIpOZ6d3JU0uwSsclaIUGxI5ifvTftOQ==} peerDependencies: '@mdx-js/mdx': '*' '@mixedbread/sdk': 0.x.x @@ -4001,6 +3971,7 @@ packages: algoliasearch: 5.x.x flexsearch: '*' lucide-react: '*' + meilisearch: '>=0.50.0 <1.0.0' next: 16.x.x react: ^19.2.0 react-dom: ^19.2.0 @@ -4034,6 +4005,8 @@ packages: optional: true lucide-react: optional: true + meilisearch: + optional: true next: optional: true react: @@ -4047,15 +4020,15 @@ packages: zod: optional: true - fumadocs-mdx@15.4.0: - resolution: {integrity: sha512-bJCQfsckKUQ4x2pyz8OdASEAARVMB49kOej7njaJ3t+tYsP1HnwkmqiO8e/jdQHcv6JH3XkXWidTho3ZC/WBcA==} + fumadocs-mdx@15.4.7: + resolution: {integrity: sha512-54lE+dIlIkebSxcN1Lqe0yn5lkKI4zm/Fxl+Se+IqSGyANsBVoa7XaDFFUSLfklxfkSM1NKkrbw0yzvRWVb9rw==} hasBin: true peerDependencies: '@fumadocs/satteri': 0.x.x '@types/mdast': '*' '@types/mdx': '*' '@types/react': '*' - fumadocs-core: ^16.15.3 + fumadocs-core: ^16.17.0 mdast-util-directive: '*' next: ^15.3.0 || ^16.0.0 react: ^19.2.0 @@ -4102,12 +4075,12 @@ packages: json-schema-typed: optional: true - fumadocs-ui@16.15.17: - resolution: {integrity: sha512-Q0Tzfte6Bzz8JCVU0yMISWjEpoTzGKfdeG8WoRxktdv30JblQ+G8n87MG11auMh6Li0CuO5oPqH5otyQ2mQ5Yw==} + fumadocs-ui@16.17.1: + resolution: {integrity: sha512-ByW1NbvEbMfsZfW+DkWewSEX7Ituv732K0y2ga1ri+Zghf+4zu2sa0QjJetC3DaKffyvx5vbPVxwjyYCeNtRhg==} peerDependencies: '@types/mdx': '*' '@types/react': '*' - fumadocs-core: 16.15.17 + fumadocs-core: 16.17.1 next: 16.x.x react: ^19.2.0 react-dom: ^19.2.0 @@ -4300,6 +4273,9 @@ packages: resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} engines: {node: '>= 0.4'} + hast-util-from-dom@5.0.1: + resolution: {integrity: sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==} + hast-util-from-parse5@8.0.3: resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==} @@ -4403,9 +4379,6 @@ packages: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} - ieee754@1.2.1: - resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} - ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -4662,11 +4635,6 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - isomorphic-git@1.37.6: - resolution: {integrity: sha512-qr1NFCPsVTZ6YGqTXw0CzamnsHyH9QQ1OTEfeXIweSljRUMzuHFCJdUn0wc6OcjtTDns6knxjPb7N6LmJeftOA==} - engines: {node: '>=14.17'} - hasBin: true - issue-parser@7.0.2: resolution: {integrity: sha512-7atWPjhGEIX3JEtMrOYd8TKzboYlq+5sNbdl9POiLYOI14G5HZiQbZP0Xj5EZdrufQVXfJlpTV0hys0CuxwxZw==} engines: {node: ^18.17 || >=20.6.1} @@ -5026,16 +4994,20 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} - lucide-react@1.47.0: - resolution: {integrity: sha512-o8C23aXpNQypRY73W7fW02EyvWJinEMXgKeGjFoKym0zj3Q73hD97A1IQps1g8C14HTGsOpZL0Am+xjfgFZAbg==} + lucide-react@1.54.0: + resolution: {integrity: sha512-kVmnVCBoLztTGe3S76xu8utwxijRLOinknNq9e8btZ0R2fm6WCgxY+weaLiA50odo/yi+apm/4B2A/LsFZhTrw==} peerDependencies: + '@types/react': '*' react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - magic-string@1.2.3: - resolution: {integrity: sha512-Bpb0W2TbLKOZ7vJnOUnVRGq3WL2p+ISV29M6hYPL1AFCpyKZpdr5ytiXoTSSxRVhg8YW7f65+6gbG8WG6PCa/g==} + magic-string@1.4.3: + resolution: {integrity: sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==} make-asynchronous@1.1.0: resolution: {integrity: sha512-ayF7iT+44LXdxJLTrTd3TLQpFDDvPCBxXxbv+pMUSuHA5Q8zyAfwkRP6aHHwNVFBUFWtxAHqwNJxF8vMZLAbVg==} @@ -5138,8 +5110,8 @@ packages: micromark-extension-gfm-strikethrough@2.1.0: resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==} - micromark-extension-gfm-table@2.1.1: - resolution: {integrity: sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==} + micromark-extension-gfm-table@2.1.2: + resolution: {integrity: sha512-pRzm4kDTu0MjlmBkxmS9yYhw60nncfcEwu9NNdPFSQEFXS95ZKyIIyTSHu/o3ReBUrLKYEq+7YaXCRn/bPB4MA==} micromark-extension-gfm-tagfilter@2.0.0: resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==} @@ -5225,9 +5197,6 @@ packages: micromark-util-symbol@2.0.1: resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} - micromark-util-types@2.0.2: - resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} - micromark-util-types@2.0.3: resolution: {integrity: sha512-oxB2Ik03hI0gv+VNn9tnh1t1YEe9MDPptViAEgfdf3YQHsn0pzGTgCdlSCJXcwhqm8phaEuM7zeEu3QQzVBrPg==} @@ -5285,9 +5254,6 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - minimisted@2.0.1: - resolution: {integrity: sha512-1oPjfuLQa2caorJUM8HV8lGgWCc0qqAO1MNv/k05G4qslmsndV/5WdNZrqCiyqiz3wohia2Ij2B7w2Dr7/IyrA==} - minipass@7.1.3: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} @@ -5297,14 +5263,14 @@ packages: engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} hasBin: true - motion-dom@13.4.4: - resolution: {integrity: sha512-z2qN3RUABSci4G7cr5aHTFhqPNCWJsEMMfRlzxqtANQsSCIbVJmHvMV288m5x7doEQBTYbRTWbKdztmquCn4Sw==} + motion-dom@14.1.0: + resolution: {integrity: sha512-FyFLkNUAAETzCvf851mZcl+v+kaZrKq+UM+XmkY4G8n+ny/WYfiWh9j0fhSQ1KOKVQrM9/cLO11THO2k71HJlg==} - motion-utils@13.3.0: - resolution: {integrity: sha512-sgSschQp7EseHInIlR7hBbMuvet3RA0bs28KPZAXJcGKGdxHGvh1ogpYDilY3bOMtl73EqPNmp75sAKHYPU5sg==} + motion-utils@14.1.0: + resolution: {integrity: sha512-xd4FeWOnnXsJHmAaRCLNbOS7mEaNOangjMt3Pl241H07f1kxW48fADQ/8O5hLLNhf1qdlozgY3PPAHBdOoU7AA==} - motion@13.4.4: - resolution: {integrity: sha512-lyX5kpAum2MmigecKWrFdIj3Sjn7pHek2EUrXY0WGZbOjJ1f5NfX0g4KQAb2Bp2EqL0a6dUzse11s1n7OYr5jg==} + motion@14.1.0: + resolution: {integrity: sha512-lsCsW9uJ+iMJj8MSiLccpUC+ZUpoSspaX6Pm78KrnZKMioCPAPG6WeG1d1d/Ywg+z1mny+STP6VyEYW+3nAqNw==} peerDependencies: react: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0 @@ -5527,9 +5493,6 @@ packages: engines: {node: '>=18.0.0'} hasBin: true - once@1.4.0: - resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - onetime@5.1.2: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} @@ -5634,9 +5597,6 @@ packages: package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} - pako@1.0.11: - resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} - param-case@3.0.4: resolution: {integrity: sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==} @@ -5733,10 +5693,6 @@ packages: resolution: {integrity: sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==} engines: {node: '>=4'} - pify@4.0.1: - resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} - engines: {node: '>=6'} - pkg-conf@2.1.0: resolution: {integrity: sha512-C+VUP+8jis7EsQZIhDYmS5qlNtjv2yP4SNtjXK9AP1ZcTRlnSfuumaTnRfYZnYgUUYVIKqL0fRvmUGDV2fmp6g==} engines: {node: '>=4'} @@ -5781,10 +5737,6 @@ packages: resolution: {integrity: sha512-JOnOPQ/8TZgjs1JIH/m9ni7FfimjNa/PRx7y/Wb5qdItsnhO0jE4AT7fC0HjC28DUQWDr50dwSYZLdRMlqDq3Q==} engines: {node: '>=8'} - process@0.11.10: - resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} - engines: {node: '>= 0.6.0'} - propagate@2.0.1: resolution: {integrity: sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==} engines: {node: '>= 8'} @@ -5872,10 +5824,6 @@ packages: readable-stream@2.3.8: resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} - readable-stream@4.7.0: - resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - readdirp@4.1.2: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} @@ -6086,11 +6034,6 @@ packages: resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} engines: {node: '>= 0.4'} - sha.js@2.4.12: - resolution: {integrity: sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==} - engines: {node: '>= 0.10'} - hasBin: true - shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -6099,8 +6042,8 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - shiki@4.4.3: - resolution: {integrity: sha512-Mb/GvXPHBAXdgGIcnfU5L3ldpn1XcxrGkPHwqgRx17/I2XRfqlFKk2vGkHWINn1kdXvzJZeuO3is6I9KLPFm0g==} + shiki@4.5.0: + resolution: {integrity: sha512-OcRW72ngQvc+MSVWBRIovC/BTW589iUE/Vxav7VeM4RcY9MUjqB+IR6ILG1k9nuW6LiG/nct9uma4/MBtnZbmQ==} engines: {node: '>=20'} side-channel-list@1.0.1: @@ -6130,12 +6073,6 @@ packages: resolution: {integrity: sha512-iuh+gPf28RkltuJC7W5MRi6XAjTDCAPC/prJUpQoG4vIP3MJZ+GTydVnodXA7pwvTKb2cA0m9OFZW/cdWy/I/w==} engines: {node: '>=6'} - simple-concat@1.0.1: - resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} - - simple-get@4.0.1: - resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} - simple-git@3.36.0: resolution: {integrity: sha512-cGQjLjK8bxJw4QuYT7gxHw3/IouVESbhahSsHrX97MzCL1gu2u7oy38W6L2ZIGECEfIBG4BabsWDPjBxJENv9Q==} @@ -6253,9 +6190,6 @@ packages: string_decoder@1.1.1: resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} - string_decoder@1.3.0: - resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} - stringify-entities@4.0.4: resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} @@ -6396,10 +6330,6 @@ packages: resolution: {integrity: sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==} engines: {node: '>=14.14'} - to-buffer@1.2.2: - resolution: {integrity: sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==} - engines: {node: '>= 0.4'} - to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -6525,10 +6455,6 @@ packages: resolution: {integrity: sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A==} engines: {node: '>=18.17'} - undici@7.27.0: - resolution: {integrity: sha512-+t2Z/GwkZQDtu00813aP66ygViGtPHKhhoFZpQKpKrE+9jIgES+Zw+mFNaDWOVRKiuJjuqKHzD3B1sfGg8+ZOQ==} - engines: {node: '>=20.18.1'} - undici@7.29.1: resolution: {integrity: sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==} engines: {node: '>=20.18.1'} @@ -6803,9 +6729,6 @@ packages: resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} engines: {node: '>=18'} - wrappy@1.0.2: - resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - write-file-atomic@3.0.3: resolution: {integrity: sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==} @@ -6887,18 +6810,24 @@ packages: resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} engines: {node: '>=18'} - yuku-analyzer@0.9.5: - resolution: {integrity: sha512-wUPmylpfXlJAIOQ6F73kPbWii7LEeyPWx++NWlJ+6NihQbO/D1YeQQjMY4Lwqg2o1RKDCHQqBfgc4ZLs0eYs5A==} + yuku-analyzer@0.17.0: + resolution: {integrity: sha512-MJWtt5HvXVR51mGOR2uMXQ45t1X1oMkmx4vaP/Li1HNC69OFG/qYmxf++aVazkXH5pdxTYTHYRUPl67b7NYxjA==} + engines: {node: '>=14.18.0'} + + yuku-ast@0.17.0: + resolution: {integrity: sha512-htIzp3bLNdBtOSh8L8pJ7nkTft2kw/C3ghTToSg3FKtzi0YGkvAdEUUza9c744tnbeXtKUMEupFOYHuVNXn1yQ==} + engines: {node: '>=14.18.0'} - yuku-ast@0.9.5: - resolution: {integrity: sha512-Q8qW8WwQnN5Cm0ZZivdRIfv0sRLTjUq0YumXJkw8CYN1aCdICH9rk4C47/4n6kA5EqDtlj+F608twoTSV2MwdQ==} + yuku-core@0.17.0: + resolution: {integrity: sha512-4BgHT4euQ62wr/9h5UWoMzt4QTrmT3WUo96aRiY2R6RRCyKjCveU23MNwNJISzYvNnbFGZ7O2E1SU5zgWqzS+Q==} + engines: {node: '>=14.18.0'} - zbsearch@4.0.0: - resolution: {integrity: sha512-gm4zfO31n2ZdruTTRQoWVWO4Q2+zrDt2GlrvIc+5JulRQNAm4IanCxER82vQ7Ug96FYkGqWUJBXFe1kGAcDWaQ==} + zbsearch@4.0.1: + resolution: {integrity: sha512-lmnEEnb93QQcNv7qARDr7jBVPxYdZbZ9XdZ0dbZ0qaOvGne4jJMW4fjwPfVaUKQXv3FNWvGbDwRDgyaSb1pc8w==} engines: {node: '>= 20.0.0'} - zod@4.5.4: - resolution: {integrity: sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==} + zod@4.6.5: + resolution: {integrity: sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==} zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -7999,7 +7928,7 @@ snapshots: optionalDependencies: '@types/react': 19.3.0 - '@fumadocs/api-docs@0.2.8(@types/react@19.3.0)(fumadocs-core@16.15.17(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.47.0(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.5.4))(fumadocs-ui@16.15.17(@types/mdx@2.0.14)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(fumadocs-core@16.15.17(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.47.0(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.5.4))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@fumadocs/api-docs@0.2.8(@types/react@19.3.0)(fumadocs-core@16.17.1(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.54.0(@types/react@19.3.0)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.5))(fumadocs-ui@16.17.1(@types/mdx@2.0.14)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(fumadocs-core@16.17.1(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.54.0(@types/react@19.3.0)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.5))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@base-ui/react': 1.8.0(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@fuma-translate/react': 1.0.2(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -8007,10 +7936,10 @@ snapshots: '@scalar/json-magic': 0.13.4 class-variance-authority: 0.7.1 cn: 0.2.6 - fumadocs-core: 16.15.17(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.47.0(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.5.4) - fumadocs-ui: 16.15.17(@types/mdx@2.0.14)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(fumadocs-core@16.15.17(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.47.0(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.5.4))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3) + fumadocs-core: 16.17.1(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.54.0(@types/react@19.3.0)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.5) + fumadocs-ui: 16.17.1(@types/mdx@2.0.14)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(fumadocs-core@16.17.1(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.54.0(@types/react@19.3.0)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.5))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3) github-slugger: 2.0.0 - lucide-react: 1.47.0(react@19.2.8) + lucide-react: 1.54.0(@types/react@19.3.0)(react@19.2.8) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) optionalDependencies: @@ -8029,7 +7958,7 @@ snapshots: - '@types/react' - react-dom - '@fumadocs/tailwind@0.1.2(tailwindcss@4.3.3)': + '@fumadocs/tailwind@0.1.3(tailwindcss@4.3.3)': optionalDependencies: tailwindcss: 4.3.3 @@ -8245,7 +8174,7 @@ snapshots: '@jridgewell/gen-mapping@0.3.13': dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/sourcemap-codec': 1.6.0 '@jridgewell/trace-mapping': 0.3.31 '@jridgewell/remapping@2.3.5': @@ -8255,12 +8184,12 @@ snapshots: '@jridgewell/resolve-uri@3.1.2': {} - '@jridgewell/sourcemap-codec@1.5.5': {} + '@jridgewell/sourcemap-codec@1.6.0': {} '@jridgewell/trace-mapping@0.3.31': dependencies: '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/sourcemap-codec': 1.6.0 '@kwsites/file-exists@1.1.1': dependencies: @@ -8936,7 +8865,7 @@ snapshots: p-filter: 4.1.0 semantic-release: 25.0.3(@typescript/typescript6@6.0.2) tinyglobby: 0.2.17 - undici: 7.27.0 + undici: 7.29.1 url-join: 5.0.0 transitivePeerDependencies: - supports-color @@ -8974,40 +8903,40 @@ snapshots: transitivePeerDependencies: - supports-color - '@shikijs/core@4.4.3': + '@shikijs/core@4.5.0': dependencies: - '@shikijs/primitive': 4.4.3 - '@shikijs/types': 4.4.3 + '@shikijs/primitive': 4.5.0 + '@shikijs/types': 4.5.0 '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.5 hast-util-to-html: 9.0.5 - '@shikijs/engine-javascript@4.4.3': + '@shikijs/engine-javascript@4.5.0': dependencies: - '@shikijs/types': 4.4.3 + '@shikijs/types': 4.5.0 '@shikijs/vscode-textmate': 10.0.2 oniguruma-to-es: 4.3.6 - '@shikijs/engine-oniguruma@4.4.3': + '@shikijs/engine-oniguruma@4.5.0': dependencies: - '@shikijs/types': 4.4.3 + '@shikijs/types': 4.5.0 '@shikijs/vscode-textmate': 10.0.2 - '@shikijs/langs@4.4.3': + '@shikijs/langs@4.5.0': dependencies: - '@shikijs/types': 4.4.3 + '@shikijs/types': 4.5.0 - '@shikijs/primitive@4.4.3': + '@shikijs/primitive@4.5.0': dependencies: - '@shikijs/types': 4.4.3 + '@shikijs/types': 4.5.0 '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.5 - '@shikijs/themes@4.4.3': + '@shikijs/themes@4.5.0': dependencies: - '@shikijs/types': 4.4.3 + '@shikijs/types': 4.5.0 - '@shikijs/types@4.4.3': + '@shikijs/types@4.5.0': dependencies: '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.5 @@ -9544,7 +9473,7 @@ snapshots: jiti: 2.7.0 magic-string: 0.30.21 prettier: 3.9.6 - zod: 4.5.4 + zod: 4.6.5 transitivePeerDependencies: - supports-color @@ -9558,7 +9487,7 @@ snapshots: '@tanstack/router-utils': 1.162.2 chokidar: 5.0.0 unplugin: 3.3.0(esbuild@0.28.2)(rolldown@1.2.8)(vite@8.2.2(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.1)) - zod: 4.5.4 + zod: 4.6.5 optionalDependencies: '@tanstack/react-router': 1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8) vite: 8.2.2(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.1) @@ -9616,7 +9545,7 @@ snapshots: ufo: 1.6.4 vitefu: 1.1.3(vite@8.2.2(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.1)) xmlbuilder2: 4.0.3 - zod: 4.5.4 + zod: 4.6.5 optionalDependencies: vite: 8.2.2(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.1) transitivePeerDependencies: @@ -9918,47 +9847,43 @@ snapshots: '@rolldown/pluginutils': 1.0.1 vite: 8.2.2(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.1) - '@yuku-analyzer/binding-android-arm64@0.9.5': + '@yuku-core/binding-android-arm64@0.17.0': optional: true - '@yuku-analyzer/binding-darwin-arm64@0.9.5': + '@yuku-core/binding-darwin-arm64@0.17.0': optional: true - '@yuku-analyzer/binding-darwin-x64@0.9.5': + '@yuku-core/binding-darwin-x64@0.17.0': optional: true - '@yuku-analyzer/binding-freebsd-x64@0.9.5': + '@yuku-core/binding-freebsd-x64@0.17.0': optional: true - '@yuku-analyzer/binding-linux-arm-gnu@0.9.5': + '@yuku-core/binding-linux-arm-gnu@0.17.0': optional: true - '@yuku-analyzer/binding-linux-arm-musl@0.9.5': + '@yuku-core/binding-linux-arm-musl@0.17.0': optional: true - '@yuku-analyzer/binding-linux-arm64-gnu@0.9.5': + '@yuku-core/binding-linux-arm64-gnu@0.17.0': optional: true - '@yuku-analyzer/binding-linux-arm64-musl@0.9.5': + '@yuku-core/binding-linux-arm64-musl@0.17.0': optional: true - '@yuku-analyzer/binding-linux-x64-gnu@0.9.5': + '@yuku-core/binding-linux-x64-gnu@0.17.0': optional: true - '@yuku-analyzer/binding-linux-x64-musl@0.9.5': + '@yuku-core/binding-linux-x64-musl@0.17.0': optional: true - '@yuku-analyzer/binding-win32-arm64@0.9.5': + '@yuku-core/binding-win32-arm64@0.17.0': optional: true - '@yuku-analyzer/binding-win32-x64@0.9.5': + '@yuku-core/binding-win32-x64@0.17.0': optional: true - '@yuku-toolchain/types@0.9.5': {} - - abort-controller@3.0.0: - dependencies: - event-target-shim: 5.0.1 + '@yuku-toolchain/types@0.17.0': {} acorn-jsx@5.3.2(acorn@8.16.0): dependencies: @@ -10104,8 +10029,6 @@ snapshots: async-function@1.0.0: {} - async-lock@1.4.1: {} - async-mutex@0.5.0: dependencies: tslib: 2.8.1 @@ -10145,8 +10068,6 @@ snapshots: balanced-match@4.0.4: {} - base64-js@1.5.1: {} - before-after-hook@4.0.0: {} bottleneck@2.19.5: {} @@ -10181,11 +10102,6 @@ snapshots: buffer-crc32@1.0.0: {} - buffer@6.0.3: - dependencies: - base64-js: 1.5.1 - ieee754: 1.2.1 - cacheable-lookup@7.0.0: {} cacheable-request@10.2.14: @@ -10309,8 +10225,6 @@ snapshots: dependencies: clsx: 2.1.1 - clean-git-ref@2.0.1: {} - clean-stack@2.2.0: {} clean-stack@3.0.1: @@ -10379,6 +10293,8 @@ snapshots: cn@0.3.3: {} + cn@0.4.0: {} + collapse-white-space@2.1.0: {} color-convert@1.9.3: @@ -10488,8 +10404,6 @@ snapshots: optionalDependencies: typescript: '@typescript/typescript6@6.0.2' - crc-32@1.2.2: {} - cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -10590,8 +10504,6 @@ snapshots: dependencies: dequal: 2.0.3 - diff3@0.0.3: {} - diff@8.0.4: {} dir-glob@3.0.1: @@ -10984,10 +10896,6 @@ snapshots: esutils@2.0.3: {} - event-target-shim@5.0.1: {} - - events@3.3.0: {} - execa@5.1.1: dependencies: cross-spawn: 7.0.6 @@ -11148,10 +11056,10 @@ snapshots: hasown: 2.0.3 mime-types: 2.1.35 - framer-motion@13.4.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + framer-motion@14.1.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: - motion-dom: 13.4.4 - motion-utils: 13.3.0 + motion-dom: 14.1.0 + motion-utils: 14.1.0 tslib: 2.8.1 optionalDependencies: react: 19.2.8 @@ -11174,27 +11082,28 @@ snapshots: fsevents@2.3.3: optional: true - fumadocs-core@16.15.17(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.47.0(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.5.4): + fumadocs-core@16.17.1(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.54.0(@types/react@19.3.0)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.5): dependencies: '@fumari/image-size': 0.1.1 estree-util-value-to-estree: 3.5.0 github-slugger: 2.0.0 + hast-util-from-dom: 5.0.1 hast-util-to-estree: 3.1.3 hast-util-to-jsx-runtime: 2.3.6 mdast-util-mdx: 3.0.0 - mdast-util-to-markdown: 2.1.3 + micromark: 4.0.2 + micromark-extension-gfm-table: 2.1.2 npm-to-yarn: 3.2.0 remark: 15.0.1 remark-gfm: 4.0.1 remark-rehype: 11.1.2 scroll-into-view-if-needed: 3.1.0 - shiki: 4.4.3 + shiki: 4.5.0 tinyglobby: 0.2.17 unified: 11.0.5 - unist-util-visit: 5.1.0 vfile: 6.0.3 yaml: 2.9.1 - zbsearch: 4.0.0 + zbsearch: 4.0.1 optionalDependencies: '@mdx-js/mdx': 3.1.1 '@tanstack/react-router': 1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -11202,23 +11111,23 @@ snapshots: '@types/hast': 3.0.5 '@types/mdast': 4.0.4 '@types/react': 19.3.0 - lucide-react: 1.47.0(react@19.2.8) + lucide-react: 1.54.0(@types/react@19.3.0)(react@19.2.8) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) - zod: 4.5.4 + zod: 4.6.5 transitivePeerDependencies: - supports-color - fumadocs-mdx@15.4.0(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.3.0)(fumadocs-core@16.15.17(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.47.0(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.5.4))(react@19.2.8)(rolldown@1.2.8)(vite@8.2.2(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.1)): + fumadocs-mdx@15.4.7(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.3.0)(fumadocs-core@16.17.1(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.54.0(@types/react@19.3.0)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.5))(react@19.2.8)(rolldown@1.2.8)(vite@8.2.2(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.1)): dependencies: '@mdx-js/mdx': 3.1.1 '@standard-schema/spec': 1.1.0 chokidar: 5.0.0 esbuild: 0.28.2 estree-util-value-to-estree: 3.5.0 - fumadocs-core: 16.15.17(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.47.0(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.5.4) + fumadocs-core: 16.17.1(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.54.0(@types/react@19.3.0)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.5) github-slugger: 2.0.0 - magic-string: 1.2.3 + magic-string: 1.4.3 mdast-util-mdx: 3.0.0 picocolors: 1.1.1 picomatch: 4.0.7 @@ -11226,11 +11135,10 @@ snapshots: tinyglobby: 0.2.17 unified: 11.0.5 unist-util-remove-position: 5.0.0 - unist-util-visit: 5.1.0 vfile: 6.0.3 yaml: 2.9.1 - yuku-analyzer: 0.9.5 - zod: 4.5.4 + yuku-analyzer: 0.17.0 + zod: 4.6.5 optionalDependencies: '@types/mdast': 4.0.4 '@types/mdx': 2.0.14 @@ -11241,7 +11149,7 @@ snapshots: transitivePeerDependencies: - supports-color - fumadocs-openapi@12.1.1(@types/react@19.3.0)(fumadocs-core@16.15.17(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.47.0(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.5.4))(fumadocs-ui@16.15.17(@types/mdx@2.0.14)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(fumadocs-core@16.15.17(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.47.0(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.5.4))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3))(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + fumadocs-openapi@12.1.1(@types/react@19.3.0)(fumadocs-core@16.17.1(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.54.0(@types/react@19.3.0)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.5))(fumadocs-ui@16.17.1(@types/mdx@2.0.14)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(fumadocs-core@16.17.1(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.54.0(@types/react@19.3.0)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.5))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3))(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: '@base-ui/react': 1.8.0(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@fuma-translate/react': 1.0.2(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -11253,16 +11161,16 @@ snapshots: chokidar: 5.0.0 class-variance-authority: 0.7.1 cn: 0.3.3 - fumadocs-core: 16.15.17(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.47.0(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.5.4) - fumadocs-ui: 16.15.17(@types/mdx@2.0.14)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(fumadocs-core@16.15.17(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.47.0(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.5.4))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3) + fumadocs-core: 16.17.1(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.54.0(@types/react@19.3.0)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.5) + fumadocs-ui: 16.17.1(@types/mdx@2.0.14)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(fumadocs-core@16.17.1(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.54.0(@types/react@19.3.0)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.5))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3) github-slugger: 2.0.0 hast-util-to-jsx-runtime: 2.3.6 - lucide-react: 1.47.0(react@19.2.8) + lucide-react: 1.54.0(@types/react@19.3.0)(react@19.2.8) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) remark: 15.0.1 remark-rehype: 11.1.2 - shiki: 4.4.3 + shiki: 4.5.0 yaml: 2.9.1 optionalDependencies: '@types/react': 19.3.0 @@ -11271,10 +11179,10 @@ snapshots: - date-fns - supports-color - fumadocs-ui@16.15.17(@types/mdx@2.0.14)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(fumadocs-core@16.15.17(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.47.0(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.5.4))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3): + fumadocs-ui@16.17.1(@types/mdx@2.0.14)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(fumadocs-core@16.17.1(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.54.0(@types/react@19.3.0)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.5))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3): dependencies: '@fuma-translate/react': 1.0.2(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@fumadocs/tailwind': 0.1.2(tailwindcss@4.3.3) + '@fumadocs/tailwind': 0.1.3(tailwindcss@4.3.3) '@radix-ui/react-accordion': 1.2.20(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-collapsible': 1.1.20(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-dialog': 1.1.23(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -11286,23 +11194,23 @@ snapshots: '@radix-ui/react-slot': 1.3.3(@types/react@19.3.0)(react@19.2.8) '@radix-ui/react-tabs': 1.1.21(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) class-variance-authority: 0.7.1 - cn: 0.3.3 - fumadocs-core: 16.15.17(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.47.0(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.5.4) - lucide-react: 1.47.0(react@19.2.8) - motion: 13.4.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + cn: 0.4.0 + fumadocs-core: 16.17.1(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.54.0(@types/react@19.3.0)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.5) + hast-util-to-jsx-runtime: 2.3.6 + lucide-react: 1.54.0(@types/react@19.3.0)(react@19.2.8) + motion: 14.1.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) next-themes: 0.4.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) react-remove-scroll: 2.7.2(@types/react@19.3.0)(react@19.2.8) - rehype-raw: 7.0.0 scroll-into-view-if-needed: 3.1.0 - shiki: 4.4.3 - unist-util-visit: 5.1.0 + shiki: 4.5.0 optionalDependencies: '@types/mdx': 2.0.14 '@types/react': 19.3.0 transitivePeerDependencies: - '@types/react-dom' + - supports-color - tailwindcss function-bind@1.1.2: {} @@ -11482,6 +11390,12 @@ snapshots: dependencies: function-bind: 1.1.2 + hast-util-from-dom@5.0.1: + dependencies: + '@types/hast': 3.0.5 + hastscript: 9.0.1 + web-namespaces: 2.0.1 + hast-util-from-parse5@8.0.3: dependencies: '@types/hast': 3.0.5 @@ -11671,8 +11585,6 @@ snapshots: dependencies: safer-buffer: 2.1.2 - ieee754@1.2.1: {} - ignore@5.3.2: {} ignore@7.0.5: {} @@ -11882,20 +11794,6 @@ snapshots: isexe@2.0.0: {} - isomorphic-git@1.37.6: - dependencies: - async-lock: 1.4.1 - clean-git-ref: 2.0.1 - crc-32: 1.2.2 - diff3: 0.0.3 - ignore: 5.3.2 - minimisted: 2.0.1 - pako: 1.0.11 - pify: 4.0.1 - readable-stream: 4.7.0 - sha.js: 2.4.12 - simple-get: 4.0.1 - issue-parser@7.0.2: dependencies: lodash.capitalize: 4.2.1 @@ -12220,17 +12118,19 @@ snapshots: dependencies: yallist: 3.1.1 - lucide-react@1.47.0(react@19.2.8): + lucide-react@1.54.0(@types/react@19.3.0)(react@19.2.8): dependencies: react: 19.2.8 + optionalDependencies: + '@types/react': 19.3.0 magic-string@0.30.21: dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/sourcemap-codec': 1.6.0 - magic-string@1.2.3: + magic-string@1.4.3: dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/sourcemap-codec': 1.6.0 make-asynchronous@1.1.0: dependencies: @@ -12458,7 +12358,7 @@ snapshots: micromark-util-character: 2.1.1 micromark-util-sanitize-uri: 2.0.1 micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 + micromark-util-types: 2.0.3 micromark-extension-gfm-footnote@2.1.0: dependencies: @@ -12469,7 +12369,7 @@ snapshots: micromark-util-normalize-identifier: 2.0.1 micromark-util-sanitize-uri: 2.0.1 micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 + micromark-util-types: 2.0.3 micromark-extension-gfm-strikethrough@2.1.0: dependencies: @@ -12478,19 +12378,19 @@ snapshots: micromark-util-classify-character: 2.0.1 micromark-util-resolve-all: 2.0.1 micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 + micromark-util-types: 2.0.3 - micromark-extension-gfm-table@2.1.1: + micromark-extension-gfm-table@2.1.2: dependencies: devlop: 1.1.0 micromark-factory-space: 2.0.1 micromark-util-character: 2.1.1 micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 + micromark-util-types: 2.0.3 micromark-extension-gfm-tagfilter@2.0.0: dependencies: - micromark-util-types: 2.0.2 + micromark-util-types: 2.0.3 micromark-extension-gfm-task-list-item@2.1.0: dependencies: @@ -12498,18 +12398,18 @@ snapshots: micromark-factory-space: 2.0.1 micromark-util-character: 2.1.1 micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 + micromark-util-types: 2.0.3 micromark-extension-gfm@3.0.0: dependencies: micromark-extension-gfm-autolink-literal: 2.1.0 micromark-extension-gfm-footnote: 2.1.0 micromark-extension-gfm-strikethrough: 2.1.0 - micromark-extension-gfm-table: 2.1.1 + micromark-extension-gfm-table: 2.1.2 micromark-extension-gfm-tagfilter: 2.0.0 micromark-extension-gfm-task-list-item: 2.1.0 micromark-util-combine-extensions: 2.0.1 - micromark-util-types: 2.0.2 + micromark-util-types: 2.0.3 micromark-extension-mdx-expression@3.0.1: dependencies: @@ -12674,8 +12574,6 @@ snapshots: micromark-util-symbol@2.0.1: {} - micromark-util-types@2.0.2: {} - micromark-util-types@2.0.3: {} micromark@4.0.2: @@ -12739,10 +12637,6 @@ snapshots: minimist@1.2.8: {} - minimisted@2.0.1: - dependencies: - minimist: 1.2.8 - minipass@7.1.3: {} mocha@11.7.5: @@ -12769,15 +12663,15 @@ snapshots: yargs-parser: 21.1.1 yargs-unparser: 2.0.0 - motion-dom@13.4.4: + motion-dom@14.1.0: dependencies: - motion-utils: 13.3.0 + motion-utils: 14.1.0 - motion-utils@13.3.0: {} + motion-utils@14.1.0: {} - motion@13.4.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + motion@14.1.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: - framer-motion: 13.4.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + framer-motion: 14.1.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) tslib: 2.8.1 optionalDependencies: react: 19.2.8 @@ -12985,10 +12879,6 @@ snapshots: - aws-crt - supports-color - once@1.4.0: - dependencies: - wrappy: 1.0.2 - onetime@5.1.2: dependencies: mimic-fn: 2.1.0 @@ -13091,8 +12981,6 @@ snapshots: package-json-from-dist@1.0.1: {} - pako@1.0.11: {} - param-case@3.0.4: dependencies: dot-case: 3.0.4 @@ -13185,8 +13073,6 @@ snapshots: pify@3.0.0: {} - pify@4.0.1: {} - pkg-conf@2.1.0: dependencies: find-up: 2.1.0 @@ -13224,8 +13110,6 @@ snapshots: dependencies: fromentries: 1.3.2 - process@0.11.10: {} - propagate@2.0.1: {} property-information@7.2.0: {} @@ -13321,14 +13205,6 @@ snapshots: string_decoder: 1.1.1 util-deprecate: 1.0.2 - readable-stream@4.7.0: - dependencies: - abort-controller: 3.0.0 - buffer: 6.0.3 - events: 3.3.0 - process: 0.11.10 - string_decoder: 1.3.0 - readdirp@4.1.2: {} readdirp@5.1.1: {} @@ -13645,26 +13521,20 @@ snapshots: es-errors: 1.3.0 es-object-atoms: 1.1.1 - sha.js@2.4.12: - dependencies: - inherits: 2.0.4 - safe-buffer: 5.2.1 - to-buffer: 1.2.2 - shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 shebang-regex@3.0.0: {} - shiki@4.4.3: + shiki@4.5.0: dependencies: - '@shikijs/core': 4.4.3 - '@shikijs/engine-javascript': 4.4.3 - '@shikijs/engine-oniguruma': 4.4.3 - '@shikijs/langs': 4.4.3 - '@shikijs/themes': 4.4.3 - '@shikijs/types': 4.4.3 + '@shikijs/core': 4.5.0 + '@shikijs/engine-javascript': 4.5.0 + '@shikijs/engine-oniguruma': 4.5.0 + '@shikijs/langs': 4.5.0 + '@shikijs/themes': 4.5.0 + '@shikijs/types': 4.5.0 '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.5 @@ -13706,14 +13576,6 @@ snapshots: figures: 2.0.0 pkg-conf: 2.1.0 - simple-concat@1.0.1: {} - - simple-get@4.0.1: - dependencies: - decompress-response: 6.0.0 - once: 1.4.0 - simple-concat: 1.0.1 - simple-git@3.36.0: dependencies: '@kwsites/file-exists': 1.1.1 @@ -13863,10 +13725,6 @@ snapshots: dependencies: safe-buffer: 5.1.2 - string_decoder@1.3.0: - dependencies: - safe-buffer: 5.2.1 - stringify-entities@4.0.4: dependencies: character-entities-html4: 2.1.0 @@ -13994,12 +13852,6 @@ snapshots: tmp@0.2.5: {} - to-buffer@1.2.2: - dependencies: - isarray: 2.0.5 - safe-buffer: 5.2.1 - typed-array-buffer: 1.0.3 - to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -14138,8 +13990,6 @@ snapshots: undici@6.26.0: {} - undici@7.27.0: {} - undici@7.29.1: {} unicode-emoji-modifier-base@1.0.0: {} @@ -14379,8 +14229,6 @@ snapshots: string-width: 7.2.0 strip-ansi: 7.2.0 - wrappy@1.0.2: {} - write-file-atomic@3.0.3: dependencies: imurmurhash: 0.1.4 @@ -14478,30 +14326,35 @@ snapshots: yoctocolors@2.1.2: {} - yuku-analyzer@0.9.5: + yuku-analyzer@0.17.0: + dependencies: + '@yuku-toolchain/types': 0.17.0 + yuku-ast: 0.17.0 + yuku-core: 0.17.0 + + yuku-ast@0.17.0: + dependencies: + '@yuku-toolchain/types': 0.17.0 + + yuku-core@0.17.0: dependencies: - '@yuku-toolchain/types': 0.9.5 - yuku-ast: 0.9.5 + '@yuku-toolchain/types': 0.17.0 optionalDependencies: - '@yuku-analyzer/binding-android-arm64': 0.9.5 - '@yuku-analyzer/binding-darwin-arm64': 0.9.5 - '@yuku-analyzer/binding-darwin-x64': 0.9.5 - '@yuku-analyzer/binding-freebsd-x64': 0.9.5 - '@yuku-analyzer/binding-linux-arm-gnu': 0.9.5 - '@yuku-analyzer/binding-linux-arm-musl': 0.9.5 - '@yuku-analyzer/binding-linux-arm64-gnu': 0.9.5 - '@yuku-analyzer/binding-linux-arm64-musl': 0.9.5 - '@yuku-analyzer/binding-linux-x64-gnu': 0.9.5 - '@yuku-analyzer/binding-linux-x64-musl': 0.9.5 - '@yuku-analyzer/binding-win32-arm64': 0.9.5 - '@yuku-analyzer/binding-win32-x64': 0.9.5 - - yuku-ast@0.9.5: - dependencies: - '@yuku-toolchain/types': 0.9.5 - - zbsearch@4.0.0: {} - - zod@4.5.4: {} + '@yuku-core/binding-android-arm64': 0.17.0 + '@yuku-core/binding-darwin-arm64': 0.17.0 + '@yuku-core/binding-darwin-x64': 0.17.0 + '@yuku-core/binding-freebsd-x64': 0.17.0 + '@yuku-core/binding-linux-arm-gnu': 0.17.0 + '@yuku-core/binding-linux-arm-musl': 0.17.0 + '@yuku-core/binding-linux-arm64-gnu': 0.17.0 + '@yuku-core/binding-linux-arm64-musl': 0.17.0 + '@yuku-core/binding-linux-x64-gnu': 0.17.0 + '@yuku-core/binding-linux-x64-musl': 0.17.0 + '@yuku-core/binding-win32-arm64': 0.17.0 + '@yuku-core/binding-win32-x64': 0.17.0 + + zbsearch@4.0.1: {} + + zod@4.6.5: {} zwitch@2.0.4: {} diff --git a/portal-template/src/components/mdx.tsx b/portal-template/src/components/mdx.tsx index ab05a7901..8661f5e65 100644 --- a/portal-template/src/components/mdx.tsx +++ b/portal-template/src/components/mdx.tsx @@ -1,4 +1,10 @@ import defaultMdxComponents from 'fumadocs-ui/mdx'; +import { Accordion, Accordions } from 'fumadocs-ui/components/accordion'; +import { File, Files, Folder } from 'fumadocs-ui/components/files'; +import { InlineTOC } from 'fumadocs-ui/components/inline-toc'; +import { Step, Steps } from 'fumadocs-ui/components/steps'; +import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; +import { TypeTable } from 'fumadocs-ui/components/type-table'; import { PluginInstall } from './plugin-install'; import { PluginLanguage, PluginLanguages, PluginPlatforms } from './plugin-support'; import { SdkActions } from './sdk-actions'; @@ -9,6 +15,18 @@ type MDXComponents = Record; export function getMDXComponents(components?: MDXComponents) { return { ...defaultMdxComponents, + // Fumadocs' components beyond its defaults, so an author's page can use them without importing. + Accordion, + Accordions, + File, + Files, + Folder, + InlineTOC, + Step, + Steps, + Tab, + Tabs, + TypeTable, // What the generated SDK and context plugin pages are made of. SdkCards, SdkCard, diff --git a/portal-template/src/lib/openapi.server.ts b/portal-template/src/lib/openapi.server.ts index 3e76ac18e..cc300c404 100644 --- a/portal-template/src/lib/openapi.server.ts +++ b/portal-template/src/lib/openapi.server.ts @@ -11,6 +11,14 @@ export type OpenApiSource = StaticSource<{ pageData: OpenAPIPageData; metaData: * Markdown page. */ export async function openApiSource(): Promise { - const sections = await Promise.all(Object.entries(specs).map(([slug, file]) => openApiSection(slug, file, codeSamplesFile))); + const loading = Object.entries(specs).map(([slug, file]) => openApiSection(slug, file, codeSamplesFile)); + // Under `vite dev` a document saved half written would fail every page; a build must still fail on it. + const sections = import.meta.env.DEV + ? (await Promise.allSettled(loading)).flatMap((result) => { + if (result.status === 'fulfilled') return [result.value]; + console.error('[OpenAPI] Leaving its reference pages out of the preview:', result.reason); + return []; + }) + : await Promise.all(loading); return { files: sections.flatMap((section) => section.files) }; } diff --git a/portal-template/src/routes/$.tsx b/portal-template/src/routes/$.tsx index 16c6686a6..b884d8804 100644 --- a/portal-template/src/routes/$.tsx +++ b/portal-template/src/routes/$.tsx @@ -10,9 +10,10 @@ import { absoluteUrl, canonicalLink } from '@/lib/seo'; import { useFumadocsLoader } from 'fumadocs-core/source/client'; import { usePathname } from 'fumadocs-core/framework'; import { DocsBody, DocsDescription, DocsPage, DocsTitle, ViewOptionsPopover } from 'fumadocs-ui/layouts/notebook/page'; +import { InlineTOC, type InlineTocProps } from 'fumadocs-ui/components/inline-toc'; import { staticFunctionMiddleware } from '@tanstack/start-static-server-functions'; -import { Suspense, use, type ReactNode } from 'react'; -import { useMDXComponents } from '@/components/mdx'; +import { Suspense, use, useMemo, type ReactNode } from 'react'; +import { getMDXComponents } from '@/components/mdx'; import { OpenAPIPage } from '@/components/api-page'; import { MarkdownCopyButton } from '@/components/markdown-copy-button'; import { slimOpenAPIPageProps } from '@/lib/openapi-slim'; @@ -128,6 +129,12 @@ function Content({ const { toc } = use(page.load()); const PageBody = page.body; + // Given the page's headings, so an author's `` lists them rather than crashing the + // page for want of `items`. Memoized, or each render would remount it and close it again. + const components = useMemo( + () => getMDXComponents({ InlineTOC: (props: Partial) => }), + [toc] + ); const pathname = usePathname(); // Named, or the popover reads `window.location.href` and sends the reader's query and hash on. const pageUrl = fullAddress(pathname); @@ -144,7 +151,7 @@ function Content({ {portal.pageActions ? : null} - + ); 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..57b4643cd 100644 --- a/portal-template/vite.config.ts +++ b/portal-template/vite.config.ts @@ -12,10 +12,12 @@ 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 () => { +export default defineConfig(async ({ command, isPreview }) => { const [paths, identity] = await Promise.all([readBuildPaths(), readPortalIdentity()]); - const pages = await prerenderPages(paths, identity.siteUrl); + // `vite dev` prerenders nothing, and listing the pages bundles every spec: one that fails would stop it starting. + const pages = command === 'serve' && !isPreview ? [] : await prerenderPages(paths, identity.siteUrl); const publicDir: string | false = paths.staticDir ?? false; const portalProjectDirectory = fileURLToPath(new URL('.', import.meta.url)); @@ -27,6 +29,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/action-result.ts b/src/actions/action-result.ts index 6cb6cfd6d..8f9df03cf 100644 --- a/src/actions/action-result.ts +++ b/src/actions/action-result.ts @@ -1,7 +1,7 @@ enum ResultType { Success = 0, Cancel = 130, - Failure = 1, + Failure = 1 } export class ActionResult { @@ -18,18 +18,18 @@ export class ActionResult { } static success(value?: T): ActionResult { - return new ActionResult(ResultType.Success, "Succeeded", value); + return new ActionResult(ResultType.Success, 'Succeeded', value); } - static failed(message = "Failed", error?: E): ActionResult { + static failed(message = 'Failed', error?: E): ActionResult { return new ActionResult(ResultType.Failure, message, undefined, error); } - static cancelled(message = "Cancelled"): ActionResult { + static cancelled(message = 'Cancelled'): ActionResult { return new ActionResult(ResultType.Cancel, message); } - static stopped(message = "Stopped"): ActionResult { + static stopped(message = 'Stopped'): ActionResult { return new ActionResult(ResultType.Cancel, message); } @@ -53,21 +53,6 @@ export class ActionResult { return this.resultType === ResultType.Cancel; } - public match( - onSuccess: (value: T) => R, - onFailure: (message: string) => R, - onCancel: (message: string) => R - ): R { - switch (this.resultType) { - case ResultType.Success: - return onSuccess(this.value!); - case ResultType.Failure: - return onFailure(this.message); - case ResultType.Cancel: - return onCancel(this.message); - } - } - public getValue(): T { if (!this.isSuccess()) { throw new Error(`Cannot unwrap ${ResultType[this.resultType]} result: ${this.message}`); @@ -75,10 +60,6 @@ export class ActionResult { return this.value!; } - public getValueOr(defaultValue: T): T { - return this.isSuccess() ? this.value! : defaultValue; - } - public getError(): E | undefined { return this.error; } @@ -87,11 +68,7 @@ export class ActionResult { return new ActionResult(this.resultType, this.message); } - public mapAll( - onSuccess: (value?: T) => R, - onFailure: () => R, - onCancel: () => R - ): R { + public mapAll(onSuccess: (value?: T) => R, onFailure: () => R, onCancel: () => R): R { switch (this.resultType) { case ResultType.Success: return onSuccess(this.value); @@ -101,4 +78,4 @@ export class ActionResult { return onCancel(); } } -} \ No newline at end of file +} 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/actions/portal/serve.ts b/src/actions/portal/serve.ts index 7a5e9de8d..6c48ffd7e 100644 --- a/src/actions/portal/serve.ts +++ b/src/actions/portal/serve.ts @@ -9,9 +9,10 @@ import { ProjectContext } from '../../types/project-context.js'; import { isSkippedByGlob } from '../../types/portal/content-tree.js'; import { GeneratedPages } from '../../types/portal/generated-pages.js'; import { PortalArtifacts } from '../../types/portal/portal-artifacts.js'; -import { PortalSettings, PortalSource } from '../../types/portal/portal-source.js'; +import { PortalSettings, PortalSource, WatchedSource } from '../../types/portal/portal-source.js'; import { PreviewConfig } from '../../types/portal/preview-config.js'; import { PreviewContent } from '../../types/portal/preview-content.js'; +import { PreviewSpecs } from '../../types/portal/preview-specs.js'; import { FileWatch, FileWatchService } from '../../infrastructure/file-watch-service.js'; import { NetworkService } from '../../infrastructure/network-service.js'; import { LauncherService } from '../../infrastructure/launcher-service.js'; @@ -78,7 +79,9 @@ export class PortalServeAction { contentWatch?.recheck(); } }); + const specWatch = this.watchSpecs(project, source); const closeWatches = async () => { + await specWatch?.close(); await configWatch?.close(); await contentWatch?.close(); }; @@ -147,25 +150,15 @@ export class PortalServeAction { const applied = await this.projectService.applyConfig(portalProjectDirectory, settings); if (applied.isErr()) { - this.prompts.configNotApplied(applied.error); + this.prompts.notApplied('config', applied.error, sourceDirectory); return; } - if (preview.show(config, applied.value)) { - this.prompts.configApplied(); - } + this.prompts.configShown(preview.show(config, applied.value)); onApplied(settings); }; - return this.startWatch( - (onChange) => - this.fileWatchService.watch(sourceDirectory, new FileName(APIMATIC_CONFIG_FILE_NAME), onChange, (reason) => - this.prompts.configWatchFailed(reason) - ), - applyEdit, - { - notWatched: (reason) => this.prompts.configNotWatched(reason), - thrown: (reason) => this.prompts.configNotApplied(reason) - } + return this.startWatch('config', sourceDirectory, applyEdit, (onChange, onFailed) => + this.fileWatchService.watch(sourceDirectory, new FileName(APIMATIC_CONFIG_FILE_NAME), onChange, onFailed) ); } @@ -197,30 +190,37 @@ export class PortalServeAction { checked.value.files ); if (applied.isErr()) { - this.prompts.contentNotApplied(applied.error, sourceDirectory); + this.prompts.notApplied('content', applied.error, sourceDirectory); return; } - const shown = preview.show(checked.value.notices); - if (shown.fixed) { - this.prompts.contentAccepted(sourceDirectory); - } - this.prompts.contentNotices(shown.notices, sourceDirectory); + this.prompts.contentShown(preview.show(checked.value.notices), sourceDirectory); }; - return this.startWatch( - (onChange) => - this.fileWatchService.watchTree( - contentDirectory, - onChange, - (reason) => this.prompts.contentWatchFailed(reason, sourceDirectory), - // What the build never reads, as an editor's swap file, would only have the check run again. - isSkippedByGlob - ), - check, - { - notWatched: (reason) => this.prompts.contentNotWatched(reason, sourceDirectory), - thrown: (reason) => this.prompts.contentNotChecked(reason, sourceDirectory) + // What the build never reads, as an editor's swap file, would only have the check run again. + return this.startWatch('content', sourceDirectory, check, (onChange, onFailed) => + this.fileWatchService.watchTree(contentDirectory, onChange, onFailed, isSkippedByGlob) + ); + } + + /** Held to a build's rules, as `content/` is, but the preview itself leaves out a reference it cannot read. */ + private watchSpecs(project: ProjectContext, source: PortalSource): FileWatch | undefined { + const sourceDirectory = project.sourceDirectory(); + const sourceContext = project.portalSource(); + const preview = new PreviewSpecs(source.specs); + + const check = async () => { + const checked = await sourceContext.resolveSpecs(); + if (checked.isErr()) { + preview.refuse(); + this.prompts.specRejected(checked.error, sourceDirectory); + return; } + this.prompts.specsShown(preview.show(checked.value), sourceDirectory); + }; + + // As for content: an editor's swap file, saved while a document is refused, would only repeat the refusal. + return this.startWatch('spec', sourceDirectory, check, (onChange, onFailed) => + this.fileWatchService.watchTree(source.specDirectory, onChange, onFailed, isSkippedByGlob) ); } @@ -229,21 +229,22 @@ export class PortalServeAction { * before the preview started, which can take a minute, and a save made meanwhile reached no watch. */ private startWatch( - watch: (onChange: () => Promise) => Result, + watched: WatchedSource, + sourceDirectory: DirectoryPath, handle: () => Promise, - report: { notWatched: (reason: string) => void; thrown: (reason: string) => void } + watch: (onChange: () => Promise, onFailed: (reason: string) => void) => Result ): FileWatch | undefined { // The watch drops whatever its handler throws, which would leave the preview stale without a word. const onChange = async () => { try { await handle(); } catch (error) { - report.thrown(errorMessage(error)); + this.prompts.notChecked(watched, errorMessage(error), sourceDirectory); } }; - const started = watch(onChange); + const started = watch(onChange, (reason) => this.prompts.watchFailed(watched, reason, sourceDirectory)); if (started.isErr()) { - report.notWatched(started.error); + this.prompts.notWatched(watched, started.error, sourceDirectory); return undefined; } started.value.recheck(); diff --git a/src/actions/quickstart.ts b/src/actions/quickstart.ts index b82511175..1c620ffe7 100644 --- a/src/actions/quickstart.ts +++ b/src/actions/quickstart.ts @@ -280,8 +280,10 @@ export class QuickstartAction { if (await this.fileService.isZipFile(specPath)) { return { supported: true }; } - const document = OpenApiDocument.parse(specPath.name(), await this.fileService.getContents(specPath)); - return document === undefined ? { supported: true } : document.format(); + return OpenApiDocument.parse(await this.fileService.getContents(specPath)).match( + (document) => document.format(), + (): SpecFormat => ({ supported: true }) + ); } catch { return { supported: true }; } diff --git a/src/commands/portal/serve.ts b/src/commands/portal/serve.ts index bd376f0d7..ba4d41c1c 100644 --- a/src/commands/portal/serve.ts +++ b/src/commands/portal/serve.ts @@ -11,7 +11,7 @@ const defaultPortText = String(DEFAULT_PORTAL_PORT); export default class PortalServe extends Command { static readonly summary = 'Preview your API Documentation Portal with live reload.'; - static readonly description = `Serves the portal described by 'src/apimatic.json' from your machine, reloading the browser as you edit the Markdown pages in 'src/content', reorder them in a 'nav.json', or change the 'portal', 'languages' or 'plugin' block of 'apimatic.json'. + static readonly description = `Serves the portal described by 'src/apimatic.json' from your machine, reloading the browser as you edit the Markdown pages in 'src/content', reorder them in a 'nav.json', edit the documents in 'src/spec', or change the 'portal', 'languages' or 'plugin' block of 'apimatic.json'. Adding a language or a 'plugin' block, adding or removing a page in 'src/content', creating 'src/static', changing which documents are in 'src/spec' or which folders its 'APIMATIC-META.json' merges, or changing the path in 'portal.site.url', needs the preview restarted. diff --git a/src/infrastructure/file-service.ts b/src/infrastructure/file-service.ts index 011dfee32..85692a796 100644 --- a/src/infrastructure/file-service.ts +++ b/src/infrastructure/file-service.ts @@ -9,7 +9,6 @@ import { FilePath } from '../types/file/filePath.js'; import { DirectoryPath } from '../types/file/directoryPath.js'; import { Directory, DirectoryItem } from '../types/file/directory.js'; import { FileName } from '../types/file/fileName.js'; -import { sleep } from './timer-extensions.js'; import { errorMessage } from '../utils/error-utils.js'; /** `stat` follows a link, so a link to nothing fails as though the entry were not there. */ @@ -18,6 +17,14 @@ function isDanglingLink(error: unknown): boolean { return code === 'ENOENT' || code === 'ENOTDIR'; } +interface DirectoryEntries { + fileNames: FileName[]; + subDirectories: DirectoryPath[]; +} + +/** Each folder's entries by its path, read once for the files of one call. */ +type Listings = Map>; + function spelling(name: string, entries: T[], nameOf: (entry: T) => string): T | undefined { return ( entries.find((entry) => nameOf(entry) === name) ?? @@ -35,15 +42,6 @@ export class FileService { } } - public fileExistsSync(file: FilePath): boolean { - try { - const stat = fsExtra.statSync(file.toString()); - return stat.isFile(); - } catch { - return false; - } - } - public async directoryExists(dir: DirectoryPath): Promise { try { const stat = await fsExtra.stat(dir.toString()); @@ -165,21 +163,43 @@ export class FileService { // By code point first: Windows and macOS open `logo.png` for `Logo.PNG`, where a web server would not. public async spelledOnDisk(root: DirectoryPath, file: FilePath): Promise { - const directory = await this.directorySpelledOnDisk(root, file.directory()); - if (directory === null) { - return null; - } - const { fileNames } = await this.listEntries(directory); - const match = spelling(file.name().toString(), fileNames, (candidate) => candidate.toString()); - return match === undefined ? null : new FilePath(directory, match); + const [found] = await this.spellingsOnDisk(root, [file]); + return found; + } + + /** As `spelledOnDisk` for each of `files`, listing each folder once: a split specification runs to thousands. */ + public async spellingsOnDisk(root: DirectoryPath, files: FilePath[]): Promise<(FilePath | null)[]> { + const listings: Listings = new Map(); + return await Promise.all( + files.map(async (file) => { + const directory = await this.spelledDirectory(root, file.directory(), listings); + if (directory === null) { + return null; + } + const { fileNames } = await this.listOnce(directory, listings); + const match = spelling(file.name().toString(), fileNames, (candidate) => candidate.toString()); + return match === undefined ? null : new FilePath(directory, match); + }) + ); } - /** `directory`, at or below `root`, as each folder below `root` is spelt on disk; null when one is not there. */ + /** `directory` as each folder below `root` is spelt on disk; null when one is not there, or it is not below `root`. */ public async directorySpelledOnDisk(root: DirectoryPath, directory: DirectoryPath): Promise { + return await this.spelledDirectory(root, directory, new Map()); + } + + private async spelledDirectory( + root: DirectoryPath, + directory: DirectoryPath, + listings: Listings + ): Promise { + if (!root.contains(directory)) { + return null; + } const relative = path.relative(root.toString(), directory.toString()); let found = root; for (const name of relative === '' ? [] : relative.split(path.sep)) { - const { subDirectories } = await this.listEntries(found); + const { subDirectories } = await this.listOnce(found, listings); const match = spelling(name, subDirectories, (subDirectory) => path.basename(subDirectory.toString())); if (match === undefined) { return null; @@ -189,10 +209,16 @@ export class FileService { return found; } + private listOnce(dir: DirectoryPath, listings: Listings): Promise { + const listing = listings.get(dir.toString()) ?? this.listEntries(dir); + listings.set(dir.toString(), listing); + return listing; + } + // The direct children of `dir`, split into files and directories; both empty when it cannot // be read. A symlink counts as what it points at: skipping it silently drops an entry the // user did put there. - private async listEntries(dir: DirectoryPath): Promise<{ fileNames: FileName[]; subDirectories: DirectoryPath[] }> { + private async listEntries(dir: DirectoryPath): Promise { const fileNames: FileName[] = []; const subDirectories: DirectoryPath[] = []; try { @@ -231,36 +257,6 @@ export class FileService { ); } - public async copyDirectoryExcluding( - source: DirectoryPath, - destination: DirectoryPath, - excludeNames: FileName[] - ): Promise { - await this.createDirectoryIfNotExists(destination); - - const entries = await fsExtra.readdir(source.toString()); - - await Promise.all( - entries - .filter((entry) => !excludeNames.find((exclude) => exclude.toString() === entry)) - .map(async (entry) => { - const sourcePath = path.join(source.toString(), entry); - const destPath = path.join(destination.toString(), entry); - const stat = await fsExtra.stat(sourcePath); - - if (stat.isDirectory()) { - return this.copyDirectoryExcluding( - new DirectoryPath(sourcePath), - new DirectoryPath(destPath), - excludeNames - ); - } - - return fsExtra.copyFile(sourcePath, destPath); - }) - ); - } - public async deleteFile(filePath: FilePath): Promise { const exists = await this.fileExists(filePath); if (exists) { @@ -275,25 +271,6 @@ export class FileService { } } - public async pollDeleteDirectory(dirPath: DirectoryPath, onDeleteFailurePersists: () => void): Promise { - const timeoutMs = 5 * 60 * 1000; - const deadline = Date.now() + timeoutMs; - const deleteFailurePersistsMaxDelay = Date.now() + 5 * 1000; - let actionPerformed = false; - while ( - Date.now() < deadline && - (await this.deleteDirectory(dirPath) - .then(() => false) - .catch(() => true)) - ) { - if (!actionPerformed && Date.now() > deleteFailurePersistsMaxDelay) { - onDeleteFailurePersists(); - actionPerformed = true; - } - await sleep(500); - } - } - public async getStream(filePath: FilePath) { return fs.createReadStream(filePath.toString()); } @@ -360,10 +337,6 @@ export class FileService { return true; } - public async copyToDir(source: FilePath, destination: DirectoryPath) { - await fsExtra.copyFile(source.toString(), source.replaceDirectory(destination).toString()); - } - public async isZipFile(filePath: FilePath): Promise { try { const buffer = await fsExtra.readFile(filePath.toString()); diff --git a/src/infrastructure/git-service.ts b/src/infrastructure/git-service.ts deleted file mode 100644 index be45b0e86..000000000 --- a/src/infrastructure/git-service.ts +++ /dev/null @@ -1,105 +0,0 @@ -import git from "isomorphic-git"; -import fs from "fs"; -import { DirectoryPath } from "../types/file/directoryPath.js"; -import { FileName } from "../types/file/fileName.js"; -import { FilePath } from "../types/file/filePath.js"; -import { Directory } from "../types/file/directory.js"; - -const GIT_AUTHOR = { name: "APIMatic-bot", email: "developer@apimatic.io" } as const; -const CUSTOM_BRANCH = "custom-code"; -const MAIN_BRANCH = "main"; - -export class GitService { - public async checkoutCustomBranch(dirPath: DirectoryPath): Promise { - const dir = dirPath.toString(); - - if (!await this.hasCustomBranch(dirPath)) { - await git.branch({ fs, dir, ref: CUSTOM_BRANCH }); - } - - await git.checkout({ fs, dir, ref: CUSTOM_BRANCH}); - } - - public async hasCustomBranch(dirPath: DirectoryPath): Promise { - const dir = dirPath.toString(); - const branches = await git.listBranches({ fs, dir }); - return branches.includes(CUSTOM_BRANCH); - } - - public getMergeFiles(dirPath: DirectoryPath): FilePath[] { - const gitDir = dirPath.join(".git"); - const mergeFiles = ["MERGE_HEAD", "MERGE_MODE", "MERGE_MSG"]; - return mergeFiles.map((filename) => new FilePath(gitDir, new FileName(filename))); - } - - public async forceCheckoutMainBranch(dirPath: DirectoryPath): Promise { - await git.checkout({ fs, dir: dirPath.toString(), ref: MAIN_BRANCH, force: true }); - } - - public async getDirectoryWithUpdatedFiles(dirPath: DirectoryPath): Promise { - const statusMatrix = await git.statusMatrix({ fs, dir: dirPath.toString() }); - - const relativeUncommitedFiles = statusMatrix - // include updated and resolved conflict files - .filter(([, headStatus, workdirStatus, stageStatus]) => headStatus !== workdirStatus || stageStatus === 3) - .map(([relativePath, workdirStatus, headStatus]) => { - if (headStatus === 0) { - return { fileName: new FileName(relativePath), description: "# Deleted" }; - } - if (workdirStatus === 0) { - return { fileName: new FileName(relativePath), description: "# Added" }; - } - return { fileName: new FileName(relativePath), description: "# Modified" }; - }); - - return Directory.createFromRelativePaths(dirPath, relativeUncommitedFiles); - } - - private async stageAll(dir: DirectoryPath): Promise { - const statusMatrix = await git.statusMatrix({ fs, dir: dir.toString() }); - await Promise.all( - statusMatrix - // include updated and resolved conflict files - .filter(([, headStatus, workdirStatus, stageStatus]) => headStatus !== workdirStatus || stageStatus === 3) - .map(([relativePath, , workdirStatus]) => { - if (workdirStatus === 0) { - return git.remove({ fs, dir: dir.toString(), filepath: relativePath }); - } - return git.add({ fs, dir: dir.toString(), filepath: relativePath }); - }) - ); - } - - public async getDirectoryWithUnmergedFiles(dir: DirectoryPath): Promise { - try { - await git.merge({ fs, dir: dir.toString(), ours: CUSTOM_BRANCH, theirs: MAIN_BRANCH, author: GIT_AUTHOR }); - return new Directory(dir, []); - } catch (error) { - if (error instanceof git.Errors.UnmergedPathsError) { - return Directory.createFromRelativePaths(dir, error.data.filepaths.map((filePath) => ({ fileName: new FileName(filePath), description: "# Conflicted file" }))); - } - if (error instanceof git.Errors.MergeConflictError) { - return new Directory(dir, []); - } - throw error; - } - } - - public async commitResolvedConflicts(dir: DirectoryPath): Promise { - await this.stageAll(dir); - const headOid = await git.resolveRef({ fs, dir: dir.toString(), ref: "HEAD" }); - const mainOid = await git.resolveRef({ fs, dir: dir.toString(), ref: MAIN_BRANCH }); - await git.commit({ - fs, - dir: dir.toString(), - message: `Merge branch '${MAIN_BRANCH}' into ${CUSTOM_BRANCH}`, - author: GIT_AUTHOR, - parent: [headOid, mainOid], - }); - } - - public async commitReviewedChanges(dir: DirectoryPath): Promise { - await this.stageAll(dir); - await git.commit({ fs, dir: dir.toString(), message: "feat: add customizations to generated SDK", author: GIT_AUTHOR }); - } -} diff --git a/src/infrastructure/launcher-service.ts b/src/infrastructure/launcher-service.ts index e02f5e34c..98d931dcf 100644 --- a/src/infrastructure/launcher-service.ts +++ b/src/infrastructure/launcher-service.ts @@ -1,65 +1,24 @@ -import { FilePath } from "../types/file/filePath.js"; -import { execa } from "execa"; -import os from "os"; -import { spawn } from "child_process"; -import open from "open"; -import { UrlPath } from "../types/file/urlPath.js"; -import isInCi from "is-in-ci"; -import { DirectoryPath } from "../types/file/directoryPath.js"; +import { FilePath } from '../types/file/filePath.js'; +import { execa } from 'execa'; +import os from 'os'; +import { spawn } from 'child_process'; +import open from 'open'; +import { UrlPath } from '../types/file/urlPath.js'; +import isInCi from 'is-in-ci'; +import { DirectoryPath } from '../types/file/directoryPath.js'; export class LauncherService { public async openFolderInIde(directoryPath: DirectoryPath, fileToOpen: FilePath): Promise { if (isInCi) return false; try { const args = [directoryPath.toString(), fileToOpen.toString()]; - await execa("code", args); + await execa('code', args); return true; } catch { return false; } } - public async isIdeAvailable(): Promise { - try { - const command = process.platform === "win32" ? "where" : "which"; - await execa(command, ["code"]); - return true; - } catch { - return false; - } - } - - public async openFolderInIdeWithWait( - directoryPath: DirectoryPath, - filesToOpen: FilePath[] - ): Promise { - try { - const args = [directoryPath.toString(), ...filesToOpen.map(f => f.toString())]; - await execa("code", ["--new-window", "--wait", ...args]); - return true; - } catch { - return false; - } - } - - public async openInEditor(filePath: FilePath): Promise { - if (isInCi) return; - try { - await execa("code", ["--wait", filePath.toString()]); - } catch { - // TODO: check for fallback (start) - if (process.platform === "win32") { - await execa("cmd", ["/c", "start", "/wait", "notepad", filePath.toString()], { stdio: "ignore" }); - } else if (process.platform === "darwin") { - await execa("vim", [filePath.toString()], { stdio: "inherit" }); - } - } - } - - public async openFile(filePath: FilePath): Promise { - await this.openPath(filePath.toString()); - } - public async openDirectory(directoryPath: DirectoryPath): Promise { await this.openPath(directoryPath.toString()); } @@ -77,22 +36,22 @@ export class LauncherService { let args: string[]; switch (os.platform()) { - case "win32": - command = "cmd"; - args = ["/c", "start", "", targetPath]; + case 'win32': + command = 'cmd'; + args = ['/c', 'start', '', targetPath]; break; - case "darwin": - command = "open"; + case 'darwin': + command = 'open'; args = [targetPath]; break; default: - command = "xdg-open"; + command = 'xdg-open'; args = [targetPath]; break; } try { - const child = spawn(command, args, { stdio: "ignore", detached: true }); + const child = spawn(command, args, { stdio: 'ignore', detached: true }); child.unref(); // Let it run without blocking } catch { // Silently ignore errors diff --git a/src/infrastructure/portal-project-service.ts b/src/infrastructure/portal-project-service.ts index 2dfd9fc12..ab2622372 100644 --- a/src/infrastructure/portal-project-service.ts +++ b/src/infrastructure/portal-project-service.ts @@ -2,7 +2,7 @@ import { createRequire } from 'node:module'; import path from 'node:path'; import fsExtra from 'fs-extra'; import { err, ok, Result } from 'neverthrow'; -import { DirectoryPath } from '../types/file/directoryPath.js'; +import { DirectoryPath, posix } from '../types/file/directoryPath.js'; import { FileName } from '../types/file/fileName.js'; import { FilePath } from '../types/file/filePath.js'; import { CodeSampleCatalogs } from '../types/portal/code-samples.js'; @@ -289,7 +289,8 @@ export class PortalProjectService { codeSamples: codeSamples === null ? null : codeSamples.toPosix(), contentDir: contentDirectory.toPosix(), generatedDir: projectDirectory.join(GENERATED_DIRECTORY_NAME).toPosix(), - staticDir: source.staticDirectory === null ? null : source.staticDirectory.toPosix(), + // The dev server's allow list names it, and Vite resolves a symlink before comparing. + staticDir: source.staticDirectory === null ? null : posix(canonical(source.staticDirectory.toString())), downloadsDir: downloads === null ? null : downloads.toPosix() }; diff --git a/src/infrastructure/service-error.ts b/src/infrastructure/service-error.ts index 1301808ab..313922aff 100644 --- a/src/infrastructure/service-error.ts +++ b/src/infrastructure/service-error.ts @@ -37,9 +37,6 @@ export class ServiceError { static notFound(customMessage: string): ServiceError { return new ServiceError(ServiceErrorCode.NotFound, customMessage, {}); } - static invalidResponse(customMessage: string): ServiceError { - return new ServiceError(ServiceErrorCode.InvalidResponse, customMessage, {}); - } static timeout(customMessage: string): ServiceError { return new ServiceError(ServiceErrorCode.Timeout, customMessage, {}); } @@ -55,14 +52,6 @@ export class ServiceError { return new ServiceError(ServiceErrorCode.UnAuthorized, message, {}); } - static readonly values: ServiceError[] = [ - ServiceError.NotFound, - ServiceError.ServerError, - ServiceError.NetworkError, - ServiceError.InvalidResponse, - ServiceError.UnAuthorized - ]; - private constructor( public readonly code: ServiceErrorCode, private readonly defaultMessage: string, 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/api/transform.ts b/src/prompts/api/transform.ts index 24eff00a3..20479e796 100644 --- a/src/prompts/api/transform.ts +++ b/src/prompts/api/transform.ts @@ -1,14 +1,16 @@ -import { log, isCancel, confirm } from "@clack/prompts"; -import { DirectoryPath } from "../../types/file/directoryPath.js"; -import { format as f } from "../format.js"; -import { Result } from "neverthrow"; -import { TransformationResultData } from "../../infrastructure/services/transformation-service.js"; -import { withSpinner } from "../prompt.js"; +import { log, isCancel, confirm } from '@clack/prompts'; +import { DirectoryPath } from '../../types/file/directoryPath.js'; +import { format as f } from '../format.js'; +import { Result } from 'neverthrow'; +import { TransformationResultData } from '../../infrastructure/services/transformation-service.js'; +import { withSpinner } from '../prompt.js'; export class ApiTransformPrompts { public async overwriteApi(directory: DirectoryPath): Promise { const overwrite = await confirm({ - message: `A specification file already exists at ${f.path(directory)}. Do you want to overwrite the existing file?`, + message: `A specification file already exists at ${f.path( + directory + )}. Do you want to overwrite the existing file?`, initialValue: false }); @@ -25,7 +27,7 @@ export class ApiTransformPrompts { } public async transformApi(fn: Promise>) { - return withSpinner("Transforming API", "API transformed successfully.", "API transformation failed.", fn); + return withSpinner('Transforming API', 'API transformed successfully.', 'API transformation failed.', fn); } logTransformationError(error: string): void { 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/prompts/portal/serve.ts b/src/prompts/portal/serve.ts index 8f34c69df..ed074ddd0 100644 --- a/src/prompts/portal/serve.ts +++ b/src/prompts/portal/serve.ts @@ -2,13 +2,16 @@ import { log } from '@clack/prompts'; import { once } from 'node:events'; import { APIMATIC_CONFIG_FILE_NAME } from '../../types/apimatic-config/document.js'; import { DirectoryPath } from '../../types/file/directoryPath.js'; +import { FilePath } from '../../types/file/filePath.js'; import { UrlPath } from '../../types/file/urlPath.js'; -import { ContentNotices } from '../../types/portal/content-notices.js'; import { MissingArtifacts } from '../../types/portal/generated-pages.js'; import { NAVIGATION_FILE_NAME } from '../../types/portal/portal-navigation.js'; import { METADATA_FILE_NAME } from '../../types/project-layout.js'; -import { ContentProblem, PortalSourceProblem } from '../../types/portal/portal-source.js'; +import { ContentProblem, PortalSourceProblem, WatchedSource } from '../../types/portal/portal-source.js'; +import { ContentShown } from '../../types/portal/preview-content.js'; +import { SpecsShown } from '../../types/portal/preview-specs.js'; import { PortalDevServer, PortalDevServerFailure } from '../../infrastructure/portal-dev-server-service.js'; +import { listedInProse } from '../../utils/string-utils.js'; import { Result } from 'neverthrow'; import { format as f } from '../format.js'; import { logTail, noteWrapped, withSpinner } from '../prompt.js'; @@ -18,10 +21,14 @@ import { reportContentNotices, reportContentProblems, reportSourceProblem, + specDirectoryOf, specPath, staticPath } from './source.js'; +const reportedOnRestart = (mistakes: string) => + `a mistake in ${mistakes} is only reported when the preview is restarted`; + export class PortalServePrompts { public usingFallbackPort(requestedPort: number, availablePort: number) { const message = @@ -54,6 +61,8 @@ export class PortalServePrompts { `So does removing a language from ${f.var('languages')} (updates the SDK pages) or the ` + `${f.var('plugin')} block (removes the Context Plugin pages).`, 'Mistakes in these files are reported on save, and the preview keeps what it last accepted.', + `Saving a document in ${specPath(sourceDirectory)} updates its API reference; a mistake is reported on ` + + 'save, and the preview leaves what it cannot read out of the reference until it is fixed.', '', `Restart the preview after adding a language or a ${f.var('plugin')} block (its SDK or plugin is ` + `fetched at startup), adding or removing a page, creating ${staticPath(sourceDirectory)}, ` + @@ -67,11 +76,12 @@ export class PortalServePrompts { ); } - public configApplied() { - log.success(`Applied the changes to ${f.var(APIMATIC_CONFIG_FILE_NAME)}.`); + public configShown(applied: boolean) { + if (applied) { + log.success(`Applied the changes to ${f.var(APIMATIC_CONFIG_FILE_NAME)}.`); + } } - /** Explained as `portal generate` would explain it, since the same rules refused it. */ public configRejected(problem: PortalSourceProblem, sourceDirectory: DirectoryPath) { reportSourceProblem(problem, sourceDirectory); log.message('The preview keeps showing what it last accepted until the file is fixed.'); @@ -86,10 +96,6 @@ export class PortalServePrompts { ); } - public configNotApplied(reason: string) { - log.warn(`The changes to ${f.var(APIMATIC_CONFIG_FILE_NAME)} could not be applied to the preview: ${reason}`); - } - /** Vite reads its public directory once, and a missing one is served as none. */ public staticDirectoryNotServed(sourceDirectory: DirectoryPath) { const message = @@ -98,20 +104,6 @@ export class PortalServePrompts { log.warn(message); } - public configNotWatched(reason: string) { - log.warn( - `${f.var(APIMATIC_CONFIG_FILE_NAME)} cannot be watched (${reason}), so edits to it need the preview restarted.` - ); - } - - public configWatchFailed(reason: string) { - log.warn( - `${f.var(APIMATIC_CONFIG_FILE_NAME)} is no longer watched (${reason}), so further edits to it need the ` + - `preview restarted.` - ); - } - - /** Explained as `portal generate` would explain it, since the same rules refused it. */ public contentRejected(problems: ContentProblem[], sourceDirectory: DirectoryPath) { reportContentProblems(problems, sourceDirectory); log.message( @@ -120,34 +112,66 @@ export class PortalServePrompts { ); } - public contentNotApplied(reason: string, sourceDirectory: DirectoryPath) { - log.warn(`The changes to ${contentPath(sourceDirectory)} could not be applied to the preview: ${reason}`); + public contentShown({ fixed, notices }: ContentShown, sourceDirectory: DirectoryPath) { + if (fixed) { + this.fixed('content', sourceDirectory); + } + reportContentNotices(notices, sourceDirectory); } - public contentNotChecked(reason: string, sourceDirectory: DirectoryPath) { - log.warn(`The changes to ${contentPath(sourceDirectory)} could not be checked: ${reason}`); + public specRejected(problem: PortalSourceProblem, sourceDirectory: DirectoryPath) { + reportSourceProblem(problem, sourceDirectory); + log.message( + `The preview shows what it can still read until ${specPath(sourceDirectory)} is fixed; a build would stop here.` + ); } - public contentAccepted(sourceDirectory: DirectoryPath) { - log.success(`${contentPath(sourceDirectory)} is fixed; a build would accept it again.`); + public specsShown({ fixed, change }: SpecsShown, sourceDirectory: DirectoryPath) { + if (fixed) { + this.fixed('spec', sourceDirectory); + } + if (change === null) { + return; + } + const specDirectory = specDirectoryOf(sourceDirectory); + const changes = [ + filesAre(change.added, 'now read', specDirectory), + filesAre(change.removed, 'no longer read', specDirectory) + ].filter((each) => each !== null); + if (changes.length === 0) { + log.success( + `The documents read from ${specPath(sourceDirectory)} are the ones the preview started with again; ` + + 'it no longer needs a restart.' + ); + return; + } + log.warn( + `The documents read from ${specPath(sourceDirectory)} are not the ones the preview started with: ` + + `${changes.join(', and ')}.` + ); + log.message( + 'Restart the preview to match them; until then it shows the ones it started with that are still there.' + ); } - public contentNotices(notices: ContentNotices, sourceDirectory: DirectoryPath) { - reportContentNotices(notices, sourceDirectory); + public notWatched(watched: WatchedSource, reason: string, sourceDirectory: DirectoryPath) { + const { name, untilRestart } = describeWatched(watched, sourceDirectory); + log.warn(`${name} cannot be watched (${reason}), so ${untilRestart}.`); } - public contentNotWatched(reason: string, sourceDirectory: DirectoryPath) { - log.warn( - `${contentPath(sourceDirectory)} cannot be watched (${reason}), so a mistake in a page or a ` + - `${f.var(NAVIGATION_FILE_NAME)} is only reported when the preview is restarted.` - ); + public watchFailed(watched: WatchedSource, reason: string, sourceDirectory: DirectoryPath) { + const { name, untilRestart } = describeWatched(watched, sourceDirectory); + log.warn(`${name} is no longer watched (${reason}), so ${untilRestart}.`); } - public contentWatchFailed(reason: string, sourceDirectory: DirectoryPath) { - log.warn( - `${contentPath(sourceDirectory)} is no longer watched (${reason}), so a mistake in a page or ` + - `a ${f.var(NAVIGATION_FILE_NAME)} is only reported when the preview is restarted.` - ); + public notChecked(watched: WatchedSource, reason: string, sourceDirectory: DirectoryPath) { + const { name } = describeWatched(watched, sourceDirectory); + log.warn(`The changes to ${name} could not be checked: ${reason}`); + } + + public notApplied(watched: WatchedSource, reason: string, sourceDirectory: DirectoryPath) { + const { name } = describeWatched(watched, sourceDirectory); + log.warn(`The changes to ${name} could not be applied to the preview: ${reason}`); } public stopping() { @@ -166,4 +190,34 @@ export class PortalServePrompts { public async blockExecution() { await Promise.race([once(process, 'SIGINT'), once(process, 'SIGTERM')]); } + + private fixed(watched: WatchedSource, sourceDirectory: DirectoryPath) { + log.success(`${describeWatched(watched, sourceDirectory).name} is fixed; a build would accept it again.`); + } +} + +/** How a message names what is watched, and what is lost until a restart while it is not. */ +function describeWatched( + watched: WatchedSource, + sourceDirectory: DirectoryPath +): { name: string; untilRestart: string } { + switch (watched) { + case 'config': + return { name: f.var(APIMATIC_CONFIG_FILE_NAME), untilRestart: 'edits to it need the preview restarted' }; + case 'content': + return { + name: contentPath(sourceDirectory), + untilRestart: reportedOnRestart(`a page or a ${f.var(NAVIGATION_FILE_NAME)}`) + }; + case 'spec': + return { name: specPath(sourceDirectory), untilRestart: reportedOnRestart('a specification') }; + } +} + +function filesAre(files: FilePath[], state: string, specDirectory: DirectoryPath): string | null { + if (files.length === 0) { + return null; + } + const names = listedInProse(files.map((file) => f.var(file.relativeTo(specDirectory)))); + return `${names} ${files.length === 1 ? 'is' : 'are'} ${state}`; } diff --git a/src/prompts/portal/source.ts b/src/prompts/portal/source.ts index fa72765cd..b0136e51a 100644 --- a/src/prompts/portal/source.ts +++ b/src/prompts/portal/source.ts @@ -12,7 +12,8 @@ import { ReservedAddressPage, SharedAddress, SharedEndpoint, - SpecConversion + SpecConversion, + UnreadableSpecReason } from '../../types/portal/portal-source.js'; import { Shared } from '../../types/portal/shared.js'; import { MERGE_SETTINGS } from '../../types/portal/spec-metadata.js'; @@ -40,7 +41,10 @@ const mergeTheFolders = (): string => const addingToMergeOrder = (folders: string): string => `adding ${folders} to ${f.var(MERGE_SETTINGS.order)} when the file lists folders`; -export const specPath = (sourceDirectory: DirectoryPath): string => f.path(sourceDirectory.join(SPEC_DIRECTORY_NAME)); +export const specDirectoryOf = (sourceDirectory: DirectoryPath): DirectoryPath => + sourceDirectory.join(SPEC_DIRECTORY_NAME); + +export const specPath = (sourceDirectory: DirectoryPath): string => f.path(specDirectoryOf(sourceDirectory)); export const contentPath = (sourceDirectory: DirectoryPath): string => f.path(sourceDirectory.join(CONTENT_DIRECTORY_NAME)); @@ -95,6 +99,24 @@ function reportSpecConversion({ file, format, converted, others }: SpecConversio ); } +function unreadableBecause(reason: UnreadableSpecReason): string { + switch (reason.kind) { + case 'empty': + return 'it is empty'; + case 'noValue': + return 'it holds no value'; + case 'noKey': + return 'it is not JSON, and the portal reads YAML only when it holds a key'; + case 'gone': + return 'it is no longer there'; + case 'folder': + return 'it is a folder'; + case 'invalid': + case 'unopened': + return reason.message; + } +} + const folderNames = (folders: DirectoryPath[], sourceDirectory: DirectoryPath): string => listedInProse(folders.map((folder) => f.var(folder.relativeTo(sourceDirectory)))); @@ -141,7 +163,39 @@ export function reportSourceProblem(problem: PortalSourceProblem, sourceDirector return; } case 'unreadableSpec': { - log.error(`${relative(problem.file, sourceDirectory)} could not be read as JSON or YAML.`); + const file = relative(problem.file, sourceDirectory); + const why = unreadableBecause(problem.reason); + log.error( + problem.referencedBy === null + ? `${file} could not be read: ${why}.` + : `${file}, which ${relative(problem.referencedBy, sourceDirectory)} refers to, could not be read: ${why}.` + ); + return; + } + case 'missingSpecReference': { + const { file, foundAs } = problem.missing; + const referencedBy = relative(problem.referencedBy, sourceDirectory); + const refersTo = `${referencedBy} refers to ${relative(file, sourceDirectory)}`; + if (foundAs !== null) { + log.error(`${refersTo}, but the file is ${spelt(foundAs, sourceDirectory)}.`); + return; + } + log.error(`${refersTo}, but there is no such file.`); + log.message(`A ${f.var('$ref')} names a file from the folder of the file it is written in.`); + return; + } + case 'unresolvableSpecReference': { + const { file, ref } = problem.reference; + const writtenIn = relative(file, sourceDirectory); + log.error( + ref === '' + ? `${writtenIn} has an empty ${f.var('$ref')}.` + : `${writtenIn} has the ${f.var('$ref')} ${f.var(ref)}, which the portal cannot resolve.` + ); + log.message( + `A ${f.var('$ref')} names a part of the file it is in as ${f.var('#/components/schemas/Pet')}, ` + + `and a part of another as ${f.var('./pets.yaml#/Pet')}.` + ); return; } case 'unreadableMetadata': { diff --git a/src/prompts/sdk/publish/interactive.ts b/src/prompts/sdk/publish/interactive.ts index 4b1a9e12c..fc5d729f3 100644 --- a/src/prompts/sdk/publish/interactive.ts +++ b/src/prompts/sdk/publish/interactive.ts @@ -55,10 +55,6 @@ export class SdkPublishInteractivePrompts { return DirectoryPath.fromUserInput(value); } - public sdkDirectoryCannotBeSameAsSourceDirectory() { - log.error(`SDK directory must be different from the ${f.var('src')} directory.`); - } - public async noSdkDirectoryProvided() { log.error('No SDK directory was provided.'); } diff --git a/src/types/build/build.ts b/src/types/build/build.ts index 319f4aae9..d8d040e2d 100644 --- a/src/types/build/build.ts +++ b/src/types/build/build.ts @@ -1,7 +1,6 @@ export interface BuildConfigData { generateVersionedPortal?: object; versionsPath?: string; - [key: string]: unknown; } // Immutable wrapper around the parsed APIMATIC-BUILD.json. Since version 2 the portal is @@ -11,16 +10,7 @@ export class BuildConfig { private constructor(private readonly data: BuildConfigData) {} public static parse(json: string): BuildConfig { - return BuildConfig.from(JSON.parse(json) as BuildConfigData); - } - - public static from(data: BuildConfigData): BuildConfig { - return new BuildConfig(data); - } - - // Called implicitly by JSON.stringify when the config is written back to disk. - public toJSON(): BuildConfigData { - return this.data; + return new BuildConfig(JSON.parse(json) as BuildConfigData); } public isVersioned(): boolean { diff --git a/src/types/file/directory.ts b/src/types/file/directory.ts index a3e41ca5e..49ddaf457 100644 --- a/src/types/file/directory.ts +++ b/src/types/file/directory.ts @@ -51,33 +51,6 @@ export class Directory { }; } - public async mapFilesInDirectory( - map: (rootDir: DirectoryPath, fileItem: FileItem) => Promise - ): Promise { - const mappedItems: DirectoryItem[] = []; - - for (const item of this.items) { - if (item instanceof Directory) { - const mappedSubDir = await item.mapFilesInDirectory(map); - if (!mappedSubDir.isEmpty()) { - mappedItems.push(mappedSubDir); - } - continue; - } - - const mappedItem = await map(this.directoryPath, item); - if (mappedItem) { - mappedItems.push(mappedItem); - } - } - - return new Directory(this.directoryPath, mappedItems); - } - - public isEmpty(): boolean { - return this.items.length === 0; - } - public excluding(names: FileName[]): Directory { const excluded = names.map((name) => name.toString()); const kept = this.items.filter((item) => @@ -107,43 +80,4 @@ export class Directory { } return files; } - - public static createFromRelativePaths(rootDir: DirectoryPath, fileItems: FileItem[]): Directory { - type PendingDirectoryItem = FileItem | PendingDir; - type PendingDir = { pendingDirPath: DirectoryPath; items: PendingDirectoryItem[] }; - - const buildDirectory = (pending: PendingDir): Directory => - new Directory( - pending.pendingDirPath, - pending.items.map((item) => ('pendingDirPath' in item ? buildDirectory(item) : item)) - ); - - const root: PendingDir = { pendingDirPath: rootDir, items: [] }; - for (const file of fileItems) { - const parts = file.fileName.toString().split(/[\\/]/); - let currentDir = root; - - for (let i = 0; i < parts.length; i++) { - const part = parts[i]; - const isLastPart = i === parts.length - 1; - - if (isLastPart) { - currentDir.items.push({ fileName: new FileName(part), description: file.description }); - } else { - let existingDir = currentDir.items.find( - (item) => 'pendingDirPath' in item && item.pendingDirPath.leafName() === part - ) as PendingDir | undefined; - - if (!existingDir) { - existingDir = { pendingDirPath: currentDir.pendingDirPath.join(part), items: [] }; - currentDir.items.push(existingDir); - } - - currentDir = existingDir; - } - } - } - - return buildDirectory(root); - } } 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/src/types/file/fileName.ts b/src/types/file/fileName.ts index 1fe2b6de5..51c13cb70 100644 --- a/src/types/file/fileName.ts +++ b/src/types/file/fileName.ts @@ -5,10 +5,6 @@ export class FileName { this.name = name; } - public isMarkDown() { - return this.hasExtension('.md'); - } - /** Whether the name ends in `extension`, compared without regard to case. */ public hasExtension(extension: string): boolean { return this.name.toLowerCase().endsWith(extension.toLowerCase()); diff --git a/src/types/file/urlPath.ts b/src/types/file/urlPath.ts index ed5c0b301..25aee896a 100644 --- a/src/types/file/urlPath.ts +++ b/src/types/file/urlPath.ts @@ -1,4 +1,4 @@ -import { URL } from "url"; +import { URL } from 'url'; export class UrlPath { private readonly url: string; @@ -10,7 +10,7 @@ export class UrlPath { public static create(url: string): UrlPath | undefined { try { const parsed = new URL(url); - if (["http:", "https:"].includes(parsed.protocol)) { + if (['http:', 'https:'].includes(parsed.protocol)) { return new UrlPath(url); } } catch { @@ -23,12 +23,6 @@ export class UrlPath { return this.url; } - /** True when the URL points at the local machine (`localhost` or `127.0.0.1`). */ - public isLocalhost(): boolean { - const hostname = new URL(this.url).hostname; - return hostname === "localhost" || hostname === "127.0.0.1"; - } - /** Structural equality against another URL. */ public isEqual(other: UrlPath): boolean { return this.url === other.url; diff --git a/src/types/portal-source-context.ts b/src/types/portal-source-context.ts index 1993cbfa8..8230a3cea 100644 --- a/src/types/portal-source-context.ts +++ b/src/types/portal-source-context.ts @@ -19,7 +19,6 @@ import { } from './project-layout.js'; import { PLACEHOLDER_SITE, SiteConfig, SuggestedSite } from './portal/config/site-config.js'; import { AcceptedContent, ContentFile, ContentTree, ReadPage } from './portal/content-tree.js'; -import { Endpoint } from './portal/endpoint.js'; import { GeneratedPages, PluginSource } from './portal/generated-pages.js'; import { OpenApiDocument } from './portal/openapi-document.js'; import { parsePage } from './portal/page.js'; @@ -36,9 +35,11 @@ import { PortalSource, PortalSourceProblem, PortalSpec, - SpecConversion + SpecConversion, + UnreadableSpecReason } from './portal/portal-source.js'; import { shared } from './portal/shared.js'; +import { ReferencedFile, SpecBundle } from './portal/spec-bundle.js'; import { SpecMetadata } from './portal/spec-metadata.js'; import { SpecContext } from './spec-context.js'; import { TRANSFORMATIONS_DIRECTORY_NAME, transformedFileName } from './transform-context.js'; @@ -50,6 +51,12 @@ const definitionNames = (fileNames: FileName[]): FileName[] => const isDocumentName = (name: FileName): boolean => SPEC_EXTENSIONS.some((extension) => name.hasExtension(extension)); +const NO_DOCUMENT = new Set(['empty', 'noValue', 'noKey']); + +/** A file at the top of a folder that holds no document, which the build reads past as it does any file that is no API. */ +const holdsNoDocument = (problem: PortalSourceProblem): boolean => + problem.kind === 'unreadableSpec' && NO_DOCUMENT.has(problem.reason.kind); + type DiscoveredSpecs = Pick; /** An OpenAPI 3.x document at the top of a directory, read as a spec yet to be given its slug, and the site it suggests. */ @@ -64,6 +71,12 @@ interface ReadDirectory { definitions: FilePath[]; } +/** A document at the top of a directory in a format the portal does not read, which `api transform` converts. */ +interface OtherFormat { + file: FilePath; + format: string; +} + /** The specifications read from `spec/`, and the site the first of them suggests. */ interface ReadSpecs { specs: PortalSpec[]; @@ -167,7 +180,7 @@ export class PortalSourceContext { }); } - /** The `spec/` half of `resolve`, for quickstart to refuse a project the build would refuse before writing into it. */ + /** The `spec/` half of `resolve`, for quickstart before it writes into a project, and `portal serve` on each save. */ public async resolveSpecs(): Promise> { return (await this.specs()).map(({ specs }) => specs); } @@ -455,8 +468,10 @@ export class PortalSourceContext { if (await this.fileService.isZipFile(specPath)) { return PLACEHOLDER_SITE; } - const document = await this.readDocument(specPath); - return document === undefined ? PLACEHOLDER_SITE : document.suggestedSite(); + return (await this.readDocument(specPath)).match( + (document) => document.suggestedSite(), + () => PLACEHOLDER_SITE + ); } /** @@ -596,8 +611,8 @@ export class PortalSourceContext { .map((fileName) => new FilePath(this.specDirectory, fileName)); const apis = await Promise.all( files.map(async (file) => { - const document = await this.readDocument(file); - return document !== undefined && document.namesApiFormat() && document.declaresOperations(); + const read = await this.readDocument(file); + return read.isOk() && read.value.namesApiFormat() && read.value.declaresOperations(); }) ); return files.filter((_, index) => apis[index]); @@ -613,41 +628,59 @@ export class PortalSourceContext { const definitions: FilePath[] = []; // What each one is, for the conversion a directory without an OpenAPI 3.x document is pointed to. - const otherFormats: { file: FilePath; format: string }[] = []; + const otherFormats: OtherFormat[] = []; + const noDocument: PortalSourceProblem[] = []; const documentNames = fileNames.filter(isDocumentName); for (const fileName of documentNames) { const file = new FilePath(directory, fileName); - const document = await this.readDocument(file); - if (document === undefined) { - return err({ kind: 'unreadableSpec', file }); + const read = await this.readDocument(file); + if (read.isErr() && holdsNoDocument(read.error)) { + noDocument.push(read.error); + continue; } + if (read.isErr()) { + return err(read.error); + } + const document = read.value; if (document.namesApiFormat()) { definitions.push(file); } const format = document.format(); - if (!format.supported) { - if (format.format !== null) { - otherFormats.push({ file, format: format.format }); + if (format.supported) { + const bundle = await this.readBundle(file, document); + if (bundle.isErr()) { + return err(bundle.error); } - continue; + documents.push({ spec: { file, endpoints: bundle.value.endpoints() }, site: document.suggestedSite() }); + } else if (format.format !== null) { + otherFormats.push({ file, format: format.format }); } - - documents.push({ - spec: { file, endpoints: await this.endpoints(document, file) }, - site: document.suggestedSite() - }); } - if (documents.length === 0) { - const [convertible] = otherFormats; - // No document declares its format: one the portal would read, by extension, is named before any other file. - const conversion = - convertible === undefined - ? this.conversion(new FilePath(directory, documentNames[0] ?? fileNames[0]), null, 0) - : this.conversion(convertible.file, convertible.format, otherFormats.length - 1); - return err({ kind: 'noOpenApiSpec', conversion }); - } - return ok({ documents, definitions }); + return documents.length > 0 + ? ok({ documents, definitions }) + : err(this.withoutOpenApi(directory, fileNames, documentNames, otherFormats, noDocument)); + } + + /** Why a directory has no OpenAPI 3.x document: one to convert, or the first that holds nothing when they all do. */ + private withoutOpenApi( + directory: DirectoryPath, + fileNames: FileName[], + documentNames: FileName[], + otherFormats: OtherFormat[], + noDocument: PortalSourceProblem[] + ): PortalSourceProblem { + // As a document caught empty mid-save is, rather than as one to convert. + if (noDocument.length > 0 && noDocument.length === documentNames.length) { + return noDocument[0]; + } + const [convertible] = otherFormats; + // No document declares its format: one the portal would read, by extension, is named before any other file. + const conversion = + convertible === undefined + ? this.conversion(new FilePath(directory, documentNames[0] ?? fileNames[0]), null, 0) + : this.conversion(convertible.file, convertible.format, otherFormats.length - 1); + return { kind: 'noOpenApiSpec', conversion }; } private conversion(file: FilePath, format: string | null, others: number): SpecConversion { @@ -674,25 +707,70 @@ export class PortalSourceContext { }; } - // A path item in another file is read from it; one that file refers on to again is not followed. - private async endpoints(document: OpenApiDocument, file: FilePath): Promise { - const referenced = await Promise.all( - document.pathItemReferences(file.directory()).map(async ({ path, file: target, pointer }) => { - const targetDocument = await this.readDocument(target); - return targetDocument?.endpointsAt(path, pointer) ?? []; - }) - ); - // A method beside a path item's `$ref` declares again what the file it refers to declares. - const all = [...document.endpoints(), ...referenced.flat()]; - return all.filter((endpoint, index) => index === all.findIndex((other) => other.isEqual(endpoint))); + /** Reads every file the bundler would embed into `document`, a level of `$ref`s at a time. */ + private async readBundle( + file: FilePath, + document: OpenApiDocument + ): Promise> { + const bundle = new SpecBundle(file, document); + for (let unread = bundle.unread(); unread.length > 0; unread = bundle.unread()) { + const read = Result.combine( + await Promise.all(unread.map((next) => this.readDocument(next.file, next.referencedBy))) + ); + if (read.isErr()) { + return err(read.error); + } + unread.forEach((next, index) => bundle.add(next.file, read.value[index])); + } + const [unresolvable] = bundle.unresolvable(); + if (unresolvable !== undefined) { + return err({ kind: 'unresolvableSpecReference', reference: unresolvable }); + } + const misspelt = await this.misspeltReference(bundle.referenced()); + return misspelt === undefined ? ok(bundle) : err(misspelt); } - private async readDocument(file: FilePath): Promise { - try { - return OpenApiDocument.parse(file.name(), await this.fileService.getContents(file)); - } catch { + /** The first file read only as this file system ignores case, which a build where case matters would miss. */ + private async misspeltReference(referenced: ReferencedFile[]): Promise { + const files = referenced.map(({ file }) => file); + const spelt = await this.fileService.spellingsOnDisk(this.sourceDirectory, files); + // Null outside `src/`, whose folders are not the user's, or in no listing, as a name in another Unicode form is. + const index = spelt.findIndex((foundAs, at) => foundAs !== null && !foundAs.isEqual(files[at])); + if (index === -1) { return undefined; } + const { file, referencedBy } = referenced[index]; + return { kind: 'missingSpecReference', missing: { file, foundAs: spelt[index] }, referencedBy }; + } + + private async readDocument( + file: FilePath, + referencedBy: FilePath | null = null + ): Promise> { + let contents: string; + try { + contents = await this.fileService.getContents(file); + } catch (error) { + const reason = await this.whyUnread(file, error); + if (reason.kind === 'gone' && referencedBy !== null) { + // Null only for a name listed but not there as a file, as a dangling link is. + const missing = (await this.missingFile(file)) ?? { file, foundAs: null }; + return err({ kind: 'missingSpecReference', missing, referencedBy }); + } + return err({ kind: 'unreadableSpec', file, referencedBy, reason }); + } + return OpenApiDocument.parse(contents).mapErr( + (reason): PortalSourceProblem => ({ kind: 'unreadableSpec', file, referencedBy, reason }) + ); + } + + /** A listed document gone is one caught mid-save, which the next save fixes; a `$ref` may name a folder. */ + private async whyUnread(file: FilePath, error: unknown): Promise { + if (await this.fileService.fileExists(file)) { + return { kind: 'unopened', message: errorMessage(error) }; + } + const folder = file.directory().join(file.name().toString()); + return (await this.fileService.directoryExists(folder)) ? { kind: 'folder' } : { kind: 'gone' }; } private async readMetadata(file: FilePath): Promise { diff --git a/src/types/portal/openapi-document.ts b/src/types/portal/openapi-document.ts index 41f2e4fd6..9d2a2cc18 100644 --- a/src/types/portal/openapi-document.ts +++ b/src/types/portal/openapi-document.ts @@ -1,8 +1,8 @@ -import { basename, dirname } from 'node:path'; -import { parse as parseYaml } from 'yaml'; +import { err, ok, Result } from 'neverthrow'; +import { parse as parseYaml, YAMLParseError } from 'yaml'; import { DirectoryPath } from '../file/directoryPath.js'; -import { FileName } from '../file/fileName.js'; import { FilePath } from '../file/filePath.js'; +import { errorMessage } from '../../utils/error-utils.js'; import { isJsonObject, JsonObject } from '../../utils/json-utils.js'; import { stripByteOrderMark } from '../../utils/string-utils.js'; import { PLACEHOLDER_SITE, SuggestedSite } from './config/site-config.js'; @@ -15,6 +15,20 @@ import { Endpoint } from './endpoint.js'; */ export type SpecFormat = { supported: true } | { supported: false; format: string | null }; +/** Why the portal's bundler would fail on a text, or read nothing from it; `invalid` carries what the parser said. */ +export type ParseFailure = + | { kind: 'empty' } + | { kind: 'noValue' } + | { kind: 'noKey' } + | { kind: 'invalid'; message: string }; + +/** What a document's `$ref`s name, as the portal's bundler reads them. */ +export interface DocumentReferences { + files: FilePath[]; + /** Each one, as written, that names no file and no part of a document by a JSON pointer: `''`, `'#'` or `'#pet'`. */ + unnamed: string[]; +} + /** A path item kept in another file, where `pointer` locates it; empty for the whole file. */ export interface PathItemReference { path: string; @@ -26,29 +40,86 @@ const DESCRIPTION_LIMIT = 300; const HTTP_METHODS = new Set(['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace']); -const URL_SCHEME = /^[a-z][a-z\d+.-]+:/i; +// The bundler fetches these, and reads any other `$ref`, `file:` and `urn:` included, as a path. +const FETCHED_PROTOCOLS = new Set(['http:', 'https:']); -// The options @scalar/json-magic bundles with, so a spec reads here as it does in the portal. +// How @scalar/json-magic reads each file it bundles: as JSON, else as YAML with these options when it holds a key. const YAML_OPTIONS = { merge: true, maxAliasCount: 10000 }; +const YAML_KEY = /^[^:]+:/; +// A text that opens as JSON does within this many characters is taken for JSON, and never read as YAML. +const JSON_OPENING = /^\s*[[{]/; +const JSON_OPENING_WITHIN = 50; -/** A specification as written to disk, read the one way the wizard and the build agree on. */ +// The keys @scalar/json-magic writes what it has embedded under, which it does not read again. +const BUNDLER_KEYS = new Set(['x-ext', 'x-ext-urls']); + +/** A specification's document, or a file its `$ref`s name, read the one way the wizard and the build agree on. */ export class OpenApiDocument { - private constructor(private readonly document: JsonObject) {} + private readonly document: JsonObject; - /** - * Undefined when neither parser accepts the text. A document that parses to something other - * than an object is kept, and reports itself as no specification at all. - */ - public static parse(fileName: FileName, contents: string): OpenApiDocument | undefined { + private constructor(private readonly value: unknown) { + this.document = isJsonObject(value) ? value : {}; + } + + /** Fails where the portal's bundler fails or reads nothing; a value other than an object is no specification. */ + public static parse(contents: string): Result { + const text = stripByteOrderMark(contents); + if (text.trim() === '') { + return err({ kind: 'empty' }); + } + let value: unknown; try { - // JSON is valid YAML, but the YAML parser is far slower and specs run to megabytes, - // so each extension gets the parser built for it. - const text = stripByteOrderMark(contents); - const document: unknown = fileName.hasExtension('.json') ? JSON.parse(text) : parseYaml(text, YAML_OPTIONS); - return new OpenApiDocument(isJsonObject(document) ? document : {}); - } catch { - return undefined; + value = JSON.parse(text); + } catch (error) { + if (JSON_OPENING.test(text.slice(0, JSON_OPENING_WITHIN))) { + return err(invalid(error)); + } + if (!YAML_KEY.test(text)) { + return err({ kind: 'noKey' }); + } + try { + value = parseYaml(text, YAML_OPTIONS); + } catch (yamlError) { + return err(invalid(yamlError)); + } } + return value === null ? err({ kind: 'noValue' }) : ok(new OpenApiDocument(value)); + } + + /** The `$id`s this document declares, which a `$ref` in any file bundled into it names a schema by. */ + public schemaIds(): Set { + const ids = new Set(); + walk(this.value, (node) => { + if (typeof node.$id === 'string') { + ids.add(node.$id); + } + return true; + }); + return ids; + } + + /** What this document's `$ref`s name, as the portal's bundler finds them, given the root's `$id`s. */ + public references(directory: DirectoryPath, rootIds: ReadonlySet): DocumentReferences { + const names = new Set(); + const unnamed: string[] = []; + walk(this.value, (node) => { + // The bundler resolves what is below an `$id` against the `$id`, not the file. + if (typeof node.$id === 'string') { + return false; + } + const target = reference(node); + if (target === undefined) { + return true; + } + if (target.name === '' && !target.pointer.startsWith('/')) { + unnamed.push(target.written); + } else { + names.add(target.name); + } + return false; + }); + const files = [...names].filter((name) => !rootIds.has(name)).flatMap((name) => localFile(directory, name) ?? []); + return { files, unnamed }; } /** Every operation declared inline or behind a `$ref` into this document; see `pathItemReferences` for the rest. */ @@ -59,12 +130,9 @@ export class OpenApiDocument { /** The path items kept in other local files, resolved against the `directory` this document sits in. */ public pathItemReferences(directory: DirectoryPath): PathItemReference[] { return Object.entries(this.paths()).flatMap(([path, pathItem]) => { - const reference = isJsonObject(pathItem) && typeof pathItem.$ref === 'string' ? pathItem.$ref : ''; - const [file, pointer = ''] = reference.split('#'); - if (file === '' || URL_SCHEME.test(file)) { - return []; - } - return [{ path, file: new FilePath(directory.resolve(dirname(file)), new FileName(basename(file))), pointer }]; + const target = reference(pathItem); + const file = target && localFile(directory, target.name); + return target && file ? [{ path, file, pointer: target.pointer }] : []; }); } @@ -138,6 +206,44 @@ export class OpenApiDocument { } } +/** A node's `$ref`, split as the bundler splits it: the file it names, empty for its own, and the pointer into it. */ +function reference(node: unknown): { written: string; name: string; pointer: string } | undefined { + if (!isJsonObject(node) || typeof node.$ref !== 'string') { + return undefined; + } + const [name, pointer = ''] = node.$ref.split('#'); + return { written: node.$ref, name, pointer }; +} + +/** The file a `$ref` names, resolved as the bundler resolves it; undefined for none, or a URL it fetches. */ +function localFile(directory: DirectoryPath, name: string): FilePath | undefined { + const fetched = FETCHED_PROTOCOLS.has(URL.parse(name)?.protocol ?? ''); + return name === '' || fetched ? undefined : FilePath.resolve(directory, name.replaceAll('\\', '/')); +} + +// A YAML error goes on to quote the lines around it, and V8 quotes the JSON it stopped at across line breaks. +function invalid(error: unknown): ParseFailure { + const message = errorMessage(error); + const firstLine = error instanceof YAMLParseError ? message.split('\n')[0].replace(/:$/, '') : message; + return { kind: 'invalid', message: firstLine.replace(/\s+/g, ' ').trim() }; +} + +/** Each object and list once; into an object only when `visit` says so, and never into the keys the bundler writes. */ +function walk(node: unknown, visit: (node: JsonObject) => boolean, seen = new WeakSet()): void { + if (typeof node !== 'object' || node === null || seen.has(node)) { + return; + } + seen.add(node); + if (isJsonObject(node) && !visit(node)) { + return; + } + for (const [key, value] of Object.entries(node)) { + if (!BUNDLER_KEYS.has(key)) { + walk(value, visit, seen); + } + } +} + function valueAt(document: JsonObject, pointer: string): unknown { try { const segments = pointer diff --git a/src/types/portal/portal-source.ts b/src/types/portal/portal-source.ts index f7b71d8a7..064b524f8 100644 --- a/src/types/portal/portal-source.ts +++ b/src/types/portal/portal-source.ts @@ -5,8 +5,10 @@ import { Endpoint } from './endpoint.js'; import { SuggestedSite } from './config/site-config.js'; import { ContentNotices } from './content-notices.js'; import { GeneratedPages } from './generated-pages.js'; +import { ParseFailure } from './openapi-document.js'; import { PortalConfig } from './portal-config.js'; import { Shared } from './shared.js'; +import { UnresolvableReference } from './spec-bundle.js'; /** An OpenAPI document found in `src/spec/` or in a folder merged there, with the slug its section is mounted at. */ export interface PortalSpec { @@ -19,6 +21,9 @@ export interface PortalSpec { /** An operation more than one specification declares, which one code sample per method and path cannot serve. */ export type SharedEndpoint = Shared; +/** A part of the source directory that `portal serve` watches, and checks on each save. */ +export type WatchedSource = 'config' | 'content' | 'spec'; + /** What `apimatic.json` decides about a portal, which `portal serve` reads again on every edit. */ export interface PortalSettings { config: PortalConfig; @@ -116,6 +121,13 @@ export interface SpecConversion { others: number; } +/** Why a file a specification is read from could not be read; `unopened` carries what the file system said. */ +export type UnreadableSpecReason = + | ParseFailure + | { kind: 'gone' } + | { kind: 'folder' } + | { kind: 'unopened'; message: string }; + /** Why a source directory cannot be built; each variant maps to its own message. */ export type PortalSourceProblem = | { kind: 'missingConfig' } @@ -123,7 +135,9 @@ export type PortalSourceProblem = | { kind: 'invalidConfig'; errors: string[]; missingPortal: boolean } // Every problem found in `content/`, so that one run lists all that a build would refuse. | { kind: 'invalidContent'; problems: ContentProblem[] } - | { kind: 'unreadableSpec'; file: FilePath } + | { kind: 'unreadableSpec'; file: FilePath; referencedBy: FilePath | null; reason: UnreadableSpecReason } + | { kind: 'missingSpecReference'; missing: MissingFile; referencedBy: FilePath } + | { kind: 'unresolvableSpecReference'; reference: UnresolvableReference } | { kind: 'unreadableMetadata'; file: FilePath } // Its `folders` are not read unless a metadata file merges them, and `api transform` writes into one of its own. | { kind: 'emptySpecDirectory'; folders: DirectoryPath[] } diff --git a/src/types/portal/preview-config.ts b/src/types/portal/preview-config.ts index 8d2436011..27fd6eb32 100644 --- a/src/types/portal/preview-config.ts +++ b/src/types/portal/preview-config.ts @@ -1,9 +1,10 @@ import { PortalConfig } from './portal-config.js'; +import { PreviewRefusal } from './preview-refusal.js'; /** The notice is given once, on the save that brings it about, rather than on every save after it. */ export class PreviewConfig { private shown: PortalConfig; - private refused = false; + private readonly refusal = new PreviewRefusal(); /** * `servesStatic`: the dev server serves `static/` only if it was there at startup, and a @@ -14,7 +15,7 @@ export class PreviewConfig { } public refuse(): void { - this.refused = true; + this.refusal.refuse(); } public staticDirectoryNotServed(config: PortalConfig): boolean { @@ -27,9 +28,8 @@ export class PreviewConfig { * it is accepted again. */ public show(config: PortalConfig, written: boolean): boolean { - const announce = written || this.refused; - this.refused = false; + const fixed = this.refusal.accept(); this.shown = config; - return announce; + return written || fixed; } } diff --git a/src/types/portal/preview-content.ts b/src/types/portal/preview-content.ts index 7ec18ffc2..a1a5ab280 100644 --- a/src/types/portal/preview-content.ts +++ b/src/types/portal/preview-content.ts @@ -2,11 +2,18 @@ import { FilePath } from '../file/filePath.js'; import { ContentNotices, NoTabsListed } from './content-notices.js'; import { isSameTabEntry } from './portal-navigation.js'; import { SharedTabName, TabOwner } from './portal-tabs.js'; +import { PreviewRefusal } from './preview-refusal.js'; + +/** What to say of a save the preview accepts: whether it fixes a refused one, and the notices it brings. */ +export interface ContentShown { + fixed: boolean; + notices: ContentNotices; +} /** A notice is given once, on the save that brings it about, rather than on every save after it. */ export class PreviewContent { private shown: ContentNotices; - private refused = false; + private readonly refusal = new PreviewRefusal(); /** `startup`: what the checks before the preview started reported. */ constructor(startup: ContentNotices) { @@ -14,16 +21,12 @@ export class PreviewContent { } public refuse(): void { - this.refused = true; + this.refusal.refuse(); } - /** - * Records the content as shown, and answers whether to say it is fixed, having been refused, - * and which notices to give: each kind in full when it holds something new, and none otherwise. - */ - public show(notices: ContentNotices): { fixed: boolean; notices: ContentNotices } { - const shown = { fixed: this.refused, notices: PreviewContent.since(notices, this.shown) }; - this.refused = false; + /** Records the content as shown, and answers what to say: each kind of notice in full when any of it is new. */ + public show(notices: ContentNotices): ContentShown { + const shown = { fixed: this.refusal.accept(), notices: PreviewContent.since(notices, this.shown) }; this.shown = notices; return shown; } diff --git a/src/types/portal/preview-refusal.ts b/src/types/portal/preview-refusal.ts new file mode 100644 index 000000000..fb1790cac --- /dev/null +++ b/src/types/portal/preview-refusal.ts @@ -0,0 +1,15 @@ +/** A save the preview refused, so that the first one it accepts after it, and only that one, is said to fix it. */ +export class PreviewRefusal { + private refused = false; + + public refuse(): void { + this.refused = true; + } + + /** Whether this accepted save fixes one refused before it. */ + public accept(): boolean { + const fixed = this.refused; + this.refused = false; + return fixed; + } +} diff --git a/src/types/portal/preview-specs.ts b/src/types/portal/preview-specs.ts new file mode 100644 index 000000000..bf3e19406 --- /dev/null +++ b/src/types/portal/preview-specs.ts @@ -0,0 +1,52 @@ +import { FilePath } from '../file/filePath.js'; +import { PortalSpec } from './portal-source.js'; +import { PreviewRefusal } from './preview-refusal.js'; + +/** Since the preview started: the documents now read from `spec/`, and the ones no longer read. */ +export interface SpecsChange { + added: FilePath[]; + removed: FilePath[]; +} + +/** + * What to say of a save the preview accepts: whether it fixes a refused one, and a change not yet said, which + * adds and removes nothing once the documents are the ones the preview started with again. + */ +export interface SpecsShown { + fixed: boolean; + change: SpecsChange | null; +} + +/** The preview serves only the documents it started with, so a change to which are read from `spec/` is said once. */ +export class PreviewSpecs { + private readonly served: FilePath[]; + private shown: FilePath[]; + private readonly refusal = new PreviewRefusal(); + + /** `startup`: the documents the preview started with. */ + constructor(startup: PortalSpec[]) { + this.served = startup.map(({ file }) => file); + this.shown = this.served; + } + + public refuse(): void { + this.refusal.refuse(); + } + + /** Records the documents as shown, and answers what to say of them. */ + public show(specs: PortalSpec[]): SpecsShown { + const files = specs.map(({ file }) => file); + const said = isSameSet(files, this.shown); + this.shown = files; + const change = { added: notIn(files, this.served), removed: notIn(this.served, files) }; + return { fixed: this.refusal.accept(), change: said ? null : change }; + } +} + +function notIn(files: FilePath[], others: FilePath[]): FilePath[] { + return files.filter((file) => !others.some((other) => other.isEqual(file))); +} + +function isSameSet(left: FilePath[], right: FilePath[]): boolean { + return notIn(left, right).length === 0 && notIn(right, left).length === 0; +} diff --git a/src/types/portal/spec-bundle.ts b/src/types/portal/spec-bundle.ts new file mode 100644 index 000000000..585e4e290 --- /dev/null +++ b/src/types/portal/spec-bundle.ts @@ -0,0 +1,79 @@ +import { FilePath } from '../file/filePath.js'; +import { Endpoint } from './endpoint.js'; +import { OpenApiDocument } from './openapi-document.js'; + +/** A file a `$ref` names, with the file the `$ref` is written in. */ +export interface ReferencedFile { + file: FilePath; + referencedBy: FilePath; +} + +/** A `$ref` the portal's bundler fails on, as written, with the file it is written in. */ +export interface UnresolvableReference { + file: FilePath; + ref: string; +} + +/** A specification and every file the portal's bundler embeds into it, added as each is read. */ +export class SpecBundle { + private readonly rootIds: ReadonlySet; + private readonly documents = new Map(); + private readonly named = new Map(); + // Those of `named` not yet read, so a chain of files a level apiece is not scanned whole at each level. + private pending: ReferencedFile[] = []; + private readonly unresolved: UnresolvableReference[] = []; + + constructor(private readonly file: FilePath, private readonly document: OpenApiDocument) { + this.rootIds = document.schemaIds(); + this.add(file, document); + } + + /** Records `document` as read from `file`, and names the files it refers to. */ + public add(file: FilePath, document: OpenApiDocument): void { + this.documents.set(key(file), document); + const { files, unnamed } = document.references(file.directory(), this.rootIds); + for (const referenced of files) { + if (!this.named.has(key(referenced))) { + const named = { file: referenced, referencedBy: file }; + this.named.set(key(referenced), named); + this.pending.push(named); + } + } + // An empty `$ref` fails anywhere; `#` and `#pet` only in the root, as the bundler makes an embedded file's pointers. + const isRoot = file.isEqual(this.file); + for (const ref of unnamed.filter((written) => isRoot || written === '')) { + this.unresolved.push({ file, ref }); + } + } + + /** The `$ref`s read so far that name no file the bundler can read, in the order read. */ + public unresolvable(): UnresolvableReference[] { + return [...this.unresolved]; + } + + /** The files named but not read yet, each with the first file to name it. */ + public unread(): ReferencedFile[] { + this.pending = this.pending.filter(({ file }) => !this.documents.has(key(file))); + return [...this.pending]; + } + + /** Every file named, each with the first file to name it. */ + public referenced(): ReferencedFile[] { + return [...this.named.values()]; + } + + /** Its operations; a path item in another file is read from it, but not one that file refers on to. */ + public endpoints(): Endpoint[] { + const mounted = this.document + .pathItemReferences(this.file.directory()) + .flatMap(({ path, file, pointer }) => this.documents.get(key(file))?.endpointsAt(path, pointer) ?? []); + // A method beside a path item's `$ref` declares again what the file it refers to declares. + const all = [...this.document.endpoints(), ...mounted]; + return all.filter((endpoint, index) => index === all.findIndex((other) => other.isEqual(endpoint))); + } +} + +// By path rather than by `isEqual`: a split specification runs to thousands of files. +function key(file: FilePath): string { + return file.toString(); +} diff --git a/src/types/sdk/generate.ts b/src/types/sdk/generate.ts index 21cce16a8..749c5b660 100644 --- a/src/types/sdk/generate.ts +++ b/src/types/sdk/generate.ts @@ -64,10 +64,6 @@ export class CodegenOption { return new CodegenOption(version, stability); } - public codeGenerationVersion(): CodeGenerationVersion { - return this.version; - } - public stabilityLevel(): Stability { return this.stability; } diff --git a/test/actions/plugin/generate.test.ts b/test/actions/plugin/generate.test.ts index e308b7197..bd150024c 100644 --- a/test/actions/plugin/generate.test.ts +++ b/test/actions/plugin/generate.test.ts @@ -87,7 +87,6 @@ describe('PluginGenerateAction', () => { pluginArchive = await fsExtra.readFile(archivePath.toString()); await fsExtra.ensureDir(sourceDirectory); - await fsExtra.writeJson(path.join(sourceDirectory, 'APIMATIC-BUILD.json'), {}); await writeConfig({ plugin: PLUGIN, languages: LANGUAGES }); // The spinner would render to stdout; pass the underlying promise straight through. @@ -125,14 +124,6 @@ describe('PluginGenerateAction', () => { expect((await execute()).isFailed()).to.be.true; expect(generatePlugin.called).to.be.false; }); - - it('generates without an APIMATIC-BUILD.json, which only portal and v3 SDK builds need', async () => { - const generatePlugin = generated(); - await fsExtra.remove(path.join(sourceDirectory, 'APIMATIC-BUILD.json')); - - expect((await execute()).isSuccess()).to.be.true; - expect(generatePlugin.called).to.be.true; - }); }); describe('overwrite guard', () => { @@ -177,13 +168,14 @@ describe('PluginGenerateAction', () => { // The server reads the plugin's identity and languages from the `apimatic.json` in the upload. describe('the upload', () => { - it('is src as it stands, apimatic.json beside the build file', async () => { + it('is src as it stands, apimatic.json beside the spec', async () => { generated(); + await fsExtra.outputFile(path.join(sourceDirectory, 'spec', 'openapi.json'), '{"openapi": "3.0.0"}'); await execute(); expect(JSON.parse(uploaded['apimatic.json'])).to.deep.equal({ plugin: PLUGIN, languages: LANGUAGES }); - expect(uploaded).to.have.property('APIMATIC-BUILD.json'); + expect(uploaded['spec/openapi.json']).to.equal('{"openapi": "3.0.0"}'); }); it('synthesizes nothing: no plugin-config.json is written for the server', async () => { 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 f29e5d5c8..60f2ba9fb 100644 --- a/test/actions/portal/serve.test.ts +++ b/test/actions/portal/serve.test.ts @@ -17,7 +17,7 @@ import { FileName } from '../../../src/types/file/fileName'; import { FilePath } from '../../../src/types/file/filePath'; import { UrlPath } from '../../../src/types/file/urlPath'; import { CommandMetadata } from '../../../src/types/common/command-metadata'; -import { PortalSource } from '../../../src/types/portal/portal-source'; +import { PortalSource, WatchedSource } from '../../../src/types/portal/portal-source'; import { Language } from '../../../src/types/sdk/generate'; import { PortalSourceContext } from '../../../src/types/portal-source-context'; import { ProjectContext } from '../../../src/types/project-context'; @@ -52,6 +52,30 @@ describe('PortalServeAction', () => { openInBrowser ); + /** Stands in for the watch on `directory`, answered by `watching`; any other tree's watch is left alone. */ + const watchTreeAt = (directory: DirectoryPath, watching: { close: sinon.SinonStub; recheck: sinon.SinonStub }) => + new Promise<{ onChange: () => Promise; onFailed: (reason: string) => void }>((resolve) => { + watchTree.callsFake( + (watched: DirectoryPath, onChange: () => Promise, onFailed: (reason: string) => void) => { + if (!watched.isEqual(directory)) { + return ok({ close: sinon.stub().resolves(), recheck: sinon.stub() }); + } + resolve({ onChange, onFailed }); + return ok(watching); + } + ); + }); + + /** The reason `report` gave each time it was called for `watched`. */ + const reasonsFor = (report: sinon.SinonStub, watched: WatchedSource) => + report.getCalls().flatMap(({ args: [about, reason] }) => (about === watched ? [reason] : [])); + + /** How many of the saves `shown` was called for it said fix one refused. */ + const fixesSaid = (shown: sinon.SinonStub) => shown.getCalls().filter(({ args: [{ fixed }] }) => fixed).length; + + /** How many saves of `apimatic.json` it said were applied. */ + const appliedSaid = () => prompts.configShown.getCalls().filter(({ args: [applied] }) => applied).length; + beforeEach(() => { root = fs.mkdtempSync(path.join(os.tmpdir(), 'portal-serve-')); @@ -227,15 +251,7 @@ describe('PortalServeAction', () => { closeWatch = sinon.stub().resolves(); recheck = sinon.stub(); applyContent = sinon.stub(PortalProjectService.prototype, 'applyContent').resolves(ok(undefined)); - watched = new Promise((resolve) => { - watchTree.callsFake( - (directory: DirectoryPath, onChange: () => Promise, onFailed: (reason: string) => void) => { - expect(directory.toString()).to.equal(source.join('content').toString()); - resolve({ onChange, onFailed }); - return ok({ close: closeWatch, recheck }); - } - ); - }); + watched = watchTreeAt(source.join('content'), { close: closeWatch, recheck }); }); // The preview itself turns every page into an HTTP 500 and says nothing. @@ -264,14 +280,14 @@ describe('PortalServeAction', () => { it('says once that the content is fixed, and nothing for a save a build accepts', async () => { await whileServing(async (save) => { await save(JSON.stringify({ pages: ['intro'] })); - expect(prompts.contentRejected.called || prompts.contentAccepted.called).to.be.false; + expect(prompts.contentRejected.called || fixesSaid(prompts.contentShown) > 0).to.be.false; await save('{'); await save(JSON.stringify({ pages: ['intro'] })); await save(JSON.stringify({ pages: ['intro', '...'] })); expect(prompts.contentRejected.calledOnce).to.be.true; - expect(prompts.contentAccepted.calledOnce).to.be.true; + expect(fixesSaid(prompts.contentShown)).to.equal(1); }); }); @@ -299,8 +315,8 @@ describe('PortalServeAction', () => { await whileServing(async (save) => { await save(JSON.stringify({ title: 'Overview', pages: ['intro'] })); - expect(prompts.contentNotApplied.calledOnceWith('EACCES: permission denied')).to.be.true; - expect(prompts.contentNotices.called).to.be.false; + expect(reasonsFor(prompts.notApplied, 'content')).to.deep.equal(['EACCES: permission denied']); + expect(prompts.contentShown.called).to.be.false; }); }); @@ -310,9 +326,9 @@ describe('PortalServeAction', () => { await save(JSON.stringify({ title: 'Overview', pages: ['intro'] })); await save(JSON.stringify({ title: 'Overview', pages: ['intro', '...'] })); - const names = prompts.contentNotices + const names = prompts.contentShown .getCalls() - .map(({ args: [notices] }) => notices.sharedTabNames.map(({ name }) => name)); + .map(({ args: [{ notices }] }) => notices.sharedTabNames.map(({ name }) => name)); expect(names).to.deep.equal([['Overview'], []]); }); }); @@ -342,7 +358,7 @@ describe('PortalServeAction', () => { // What the watch does on a recheck: check the content as though it had just been saved. await (await watched).onChange(); - const [notices] = prompts.contentNotices.lastCall.args; + const [{ notices }] = prompts.contentShown.lastCall.args; expect(notices.sharedTabNames.map(({ name }) => name)).to.deep.equal(['Context Plugin']); await saveConfig(config); @@ -376,7 +392,7 @@ describe('PortalServeAction', () => { const result = await execute(new DirectoryPath(root)); - expect(prompts.contentNotWatched.calledOnceWith('EMFILE: too many open files')).to.be.true; + expect(reasonsFor(prompts.notWatched, 'content')).to.deep.equal(['EMFILE: too many open files']); expect(result.isCancelled()).to.be.true; }); @@ -384,7 +400,7 @@ describe('PortalServeAction', () => { await whileServing(async () => { (await watched).onFailed('EPERM: operation not permitted'); - expect(prompts.contentWatchFailed.calledOnceWith('EPERM: operation not permitted')).to.be.true; + expect(reasonsFor(prompts.watchFailed, 'content')).to.deep.equal(['EPERM: operation not permitted']); }); }); @@ -394,7 +410,7 @@ describe('PortalServeAction', () => { await whileServing(async (save) => { await save(JSON.stringify({ pages: ['intro'] })); - expect(prompts.contentNotChecked.calledOnceWith('EIO: i/o error')).to.be.true; + expect(reasonsFor(prompts.notChecked, 'content')).to.deep.equal(['EIO: i/o error']); }); }); @@ -408,13 +424,167 @@ describe('PortalServeAction', () => { expect(closeWatch.calledBefore(prompts.previewStopped)).to.be.true; }); - it('watches nothing when there is no content directory', async () => { + it('watches no content when there is no content directory', async () => { fs.rmSync(path.join(source.toString(), 'content'), { recursive: true }); interrupt(); await execute(new DirectoryPath(root)); - expect(watchTree.called).to.be.false; + const watchedDirectories = watchTree.getCalls().map(({ args: [directory] }) => directory.toString()); + expect(watchedDirectories).to.deep.equal([source.join('spec').toString()]); + }); + }); + + // The preview reloads a saved specification itself; the CLI's part is saying what a build would refuse. + describe('checking a save in the spec directory', () => { + let source: DirectoryPath; + let watched: Promise<{ onChange: () => Promise; onFailed: (reason: string) => void }>; + let closeWatch: sinon.SinonStub; + let recheck: sinon.SinonStub; + let written: string; + + const specFile = (name: string) => path.join(source.toString(), 'spec', name); + const writeSpec = (name: string, contents: string) => fs.writeFileSync(specFile(name), contents); + const halfWritten = () => writeSpec('Apimatic-Calculator.json', written.slice(0, written.length / 2)); + const restored = () => writeSpec('Apimatic-Calculator.json', written); + + /** Runs the preview until `body` is done with it, then stops it as CTRL+C would. */ + const whileServing = async (body: (saved: () => Promise) => Promise) => { + const running = execute(new DirectoryPath(root)); + const { onChange } = await watched; + try { + await body(onChange); + } finally { + interrupt(); + await running; + } + }; + + beforeEach(() => { + source = new DirectoryPath(root).join('src'); + fs.cpSync(FIXTURE_SOURCE.toString(), source.toString(), { recursive: true }); + written = fs.readFileSync(specFile('Apimatic-Calculator.json'), 'utf8'); + + closeWatch = sinon.stub().resolves(); + recheck = sinon.stub(); + watched = watchTreeAt(source.join('spec'), { close: closeWatch, recheck }); + }); + + // The preview itself leaves the document's reference out, and says nothing. + it('reports a document saved half written, as a build would', async () => { + await whileServing(async (saved) => { + halfWritten(); + await saved(); + + const [problem, sourceDirectory] = prompts.specRejected.firstCall.args; + expect(problem.kind === 'unreadableSpec' && problem.file.relativeTo(source)).to.equal( + 'spec/Apimatic-Calculator.json' + ); + expect(sourceDirectory.isEqual(source)).to.be.true; + }); + }); + + it('reports a file a document refers to that is not there', async () => { + const document = JSON.parse(written); + document.components.schemas.Pet = { $ref: './schemas/Pet.yaml' }; + + await whileServing(async (saved) => { + writeSpec('Apimatic-Calculator.json', JSON.stringify(document)); + await saved(); + + const [problem] = prompts.specRejected.firstCall.args; + expect(problem.kind === 'missingSpecReference' && problem.missing.file.relativeTo(source)).to.equal( + 'spec/schemas/Pet.yaml' + ); + }); + }); + + it('says once that the specifications are fixed, and nothing for a save a build accepts', async () => { + await whileServing(async (saved) => { + await saved(); + expect(prompts.specRejected.called || fixesSaid(prompts.specsShown) > 0).to.be.false; + + halfWritten(); + await saved(); + restored(); + await saved(); + await saved(); + + expect(prompts.specRejected.calledOnce).to.be.true; + expect(fixesSaid(prompts.specsShown)).to.equal(1); + }); + }); + + it('warns once that a document added needs a restart, and says once that nothing differs when it is taken out', async () => { + await whileServing(async (saved) => { + writeSpec( + 'orders.json', + JSON.stringify({ openapi: '3.0.0', info: { title: 'Orders', version: '1' }, paths: {} }) + ); + await saved(); + await saved(); + + const changes = () => prompts.specsShown.getCalls().flatMap(({ args: [{ change }] }) => change ?? []); + expect(changes()).to.have.length(1); + const [change] = changes(); + expect(change.added.map((file) => file.relativeTo(source))).to.deep.equal(['spec/orders.json']); + expect(change.removed).to.be.empty; + + fs.rmSync(specFile('orders.json')); + await saved(); + await saved(); + + expect(changes()).to.have.length(2); + expect(changes()[1]).to.deep.equal({ added: [], removed: [] }); + }); + }); + + // An editor's swap file would only have the specifications checked again for nothing. + it("leaves dot files, as an editor's swap file is, and node_modules out of the watch", async () => { + await whileServing(async () => { + const call = watchTree.getCalls().find(({ args: [directory] }) => directory.isEqual(source.join('spec'))); + const isIgnored: (name: string) => boolean = call?.args[3]; + + expect(['.Apimatic-Calculator.json.swp', 'node_modules'].every(isIgnored)).to.be.true; + expect(isIgnored('Apimatic-Calculator.json')).to.be.false; + }); + }); + + // The specifications are read before the preview starts, which can take a minute. + it('checks the specifications again once they are watched, and stops watching when the preview stops', async () => { + await whileServing(async () => { + expect(recheck.calledOnce).to.be.true; + }); + + expect(closeWatch.calledBefore(prompts.stopping)).to.be.true; + }); + + it('says when the specifications cannot be watched, and serves regardless', async () => { + watchTree.returns(err('EMFILE: too many open files')); + interrupt(); + + const result = await execute(new DirectoryPath(root)); + + expect(reasonsFor(prompts.notWatched, 'spec')).to.deep.equal(['EMFILE: too many open files']); + expect(result.isCancelled()).to.be.true; + }); + + it('tells the user when the specifications stop being watched', async () => { + await whileServing(async () => { + (await watched).onFailed('EPERM: operation not permitted'); + + expect(reasonsFor(prompts.watchFailed, 'spec')).to.deep.equal(['EPERM: operation not permitted']); + }); + }); + + it('says when a save could not be checked, rather than dropping it', async () => { + sinon.stub(PortalSourceContext.prototype, 'resolveSpecs').rejects(new Error('EIO: i/o error')); + + await whileServing(async (saved) => { + await saved(); + + expect(reasonsFor(prompts.notChecked, 'spec')).to.deep.equal(['EIO: i/o error']); + }); }); }); @@ -516,7 +686,7 @@ describe('PortalServeAction', () => { await (await watched).onChange(); expect(readProject('src/styles/theme.css')).to.contain('--color-fd-primary: #1d4ed8;'); - expect(prompts.configApplied.calledOnce).to.be.true; + expect(appliedSaid()).to.equal(1); }); }); @@ -530,7 +700,7 @@ describe('PortalServeAction', () => { expect(readProject('src/styles/theme.css')).to.contain('--color-fd-primary: #1d4ed8;'); expect(JSON.parse(readProject('portal.identity.json')).name).to.equal('Renamed API'); - expect(prompts.configApplied.calledOnce).to.be.true; + expect(appliedSaid()).to.equal(1); }); }); @@ -545,7 +715,7 @@ describe('PortalServeAction', () => { expect([readProject('portal.identity.json'), readProject('generated/sdks/typescript.mdx')]).to.deep.equal( before ); - expect(prompts.configApplied.called).to.be.false; + expect(appliedSaid()).to.equal(0); expect(prompts.configRejected.called).to.be.false; }); }); @@ -568,7 +738,7 @@ describe('PortalServeAction', () => { 'packageUrl="https://www.npmjs.com/package/calc"' ); expect(readProject('generated/sdks/index.mdx')).to.contain('install="npm install calc"'); - expect(prompts.configApplied.calledOnce).to.be.true; + expect(appliedSaid()).to.equal(1); }); }); @@ -593,11 +763,11 @@ describe('PortalServeAction', () => { }) ).to.be.true; expect(projectHas('generated/sdks/python.mdx')).to.be.false; - expect(prompts.configApplied.called).to.be.false; + expect(appliedSaid()).to.equal(0); await save(originalConfig()); - expect(prompts.configApplied.calledOnce).to.be.true; + expect(appliedSaid()).to.equal(1); }); }); @@ -619,7 +789,7 @@ describe('PortalServeAction', () => { expect(readProject('generated/context-plugin/index.mdx')).to.contain('Context Plugin'); expect(prompts.editNeedsRestart.called).to.be.false; - expect(prompts.configApplied.calledOnce).to.be.true; + expect(appliedSaid()).to.equal(1); }); }); @@ -633,7 +803,7 @@ describe('PortalServeAction', () => { await save(originalConfig()); expect(projectHas('generated/sdks/python.mdx')).to.be.false; - expect(prompts.configApplied.calledOnce).to.be.true; + expect(appliedSaid()).to.equal(1); }); }); }); @@ -666,11 +836,11 @@ describe('PortalServeAction', () => { await save(broken); expect(prompts.configRejected.firstCall.args[0].kind).to.equal('invalidConfig'); - expect(prompts.configApplied.called).to.be.false; + expect(appliedSaid()).to.equal(0); expect([readProject('portal.identity.json'), readProject('src/styles/theme.css')]).to.deep.equal(before); await save(originalConfig()); - expect(prompts.configApplied.calledOnce).to.be.true; + expect(appliedSaid()).to.equal(1); }); }); @@ -716,7 +886,7 @@ describe('PortalServeAction', () => { await save(config); expect(prompts.staticDirectoryNotServed.called).to.be.false; - expect(prompts.configApplied.calledOnce).to.be.true; + expect(appliedSaid()).to.equal(1); }); }); @@ -728,8 +898,8 @@ describe('PortalServeAction', () => { await save(config); - expect(prompts.configNotApplied.calledOnceWith('EACCES: permission denied')).to.be.true; - expect(prompts.configApplied.called).to.be.false; + expect(reasonsFor(prompts.notApplied, 'config')).to.deep.equal(['EACCES: permission denied']); + expect(appliedSaid()).to.equal(0); }); }); @@ -743,10 +913,10 @@ describe('PortalServeAction', () => { await save(config); - expect(prompts.configNotApplied.calledOnceWith('EBUSY: resource busy or locked')).to.be.true; + expect(reasonsFor(prompts.notChecked, 'config')).to.deep.equal(['EBUSY: resource busy or locked']); resolveSettings.restore(); await save(config); - expect(prompts.configApplied.calledOnce).to.be.true; + expect(appliedSaid()).to.equal(1); }); }); @@ -754,7 +924,7 @@ describe('PortalServeAction', () => { await whileServing(async () => { (await watched).onFailed('EPERM: operation not permitted'); - expect(prompts.configWatchFailed.calledOnceWith('EPERM: operation not permitted')).to.be.true; + expect(reasonsFor(prompts.watchFailed, 'config')).to.deep.equal(['EPERM: operation not permitted']); }); }); @@ -764,7 +934,7 @@ describe('PortalServeAction', () => { const result = await execute(new DirectoryPath(root)); - expect(prompts.configNotWatched.calledOnceWith('EMFILE: too many open files')).to.be.true; + expect(reasonsFor(prompts.notWatched, 'config')).to.deep.equal(['EMFILE: too many open files']); expect(result.isCancelled()).to.be.true; }); }); diff --git a/test/e2e/portal-build.test.ts b/test/e2e/portal-build.test.ts index 2aea5eae4..9e6fc12a6 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, @@ -412,6 +404,37 @@ const stylesheetOf = (output: DirectoryPath) => { expect(read('guides/intro/index.html')).to.not.contain(' { + const page = read('guides/components/index.html'); + + for (const rendered of [ + 'Inline contents', + 'Default callout body.', + 'Idea title', + 'Composed callout body.', + 'href="/guides/intro"', + 'Plain card body.', + 'client.ts', + 'python tab body', + 'Composed first', + 'Tab one', + 'First tab body.', + 'Right tab body.', + 'fd-step', + 'Accordion title', + 'Multiple two', + 'file-name.ts', + 'closed-folder', + 'typeTableField', + 'deprecatedField', + 'A guide from the fixture.' + ]) { + expect(page, `no ${rendered} on the page`).to.contain(rendered); + } + }); + // Emphasis reaches these files through a Markdown serializer that has broken on it twice: once // mangling the markers, once looping over one until the stack ran out. It has to read as written. it("keeps the emphasis in a language's SDK docs, in its twin and in llms-full.txt", () => { @@ -491,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"'); @@ -532,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)); @@ -813,6 +844,17 @@ const stylesheetOf = (output: DirectoryPath) => { expect(read('authentication/index.html')).to.match(/]* src="\/api\/assets\/diagram[^"]*\.png"/); }); + // Fumadocs swapped a bundled image for its import's name, `` (fuma-nama/fumadocs#3662). + it('keeps the images as written in the Markdown twins and llms-full.txt', () => { + const full = read('llms-full.txt'); + + expect(read('index.md')).to.contain('![How a calculation flows](/images/diagram.png)'); + expect(read('authentication.md')).to.contain('![The flow, by reference][flow]'); + expect(read('authentication.md')).to.contain('[flow]: /images/diagram.png'); + expect(full).to.contain('![How a calculation flows](/images/diagram.png)'); + expect(full).to.not.contain('__img'); + }); + // Fumadocs' own renderer fails the whole page over an image in a description; `ApiMarkdown` stands in for it. it('renders an image and a code block in a description, the image under the path', () => { const page = read('api/apimatic-calculator/simple-calculator/Calculate/index.html'); diff --git a/test/e2e/portal-serve.test.ts b/test/e2e/portal-serve.test.ts index b02122dde..b11324e75 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,8 @@ 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'; +import { sleep } from '../../src/infrastructure/timer-extensions'; // Opt-in: a real dev server needs a minute and every runtime dependency. const enabled = process.env.APIMATIC_E2E === '1'; @@ -21,15 +20,17 @@ 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; + const specFile = (source: DirectoryPath) => path.join(source.toString(), 'spec', 'Apimatic-Calculator.json'); + const reference = '/api/apimatic-calculator/simple-calculator/Calculate.md'; + const roots: string[] = []; + const servers: PortalDevServer[] = []; - before(async () => { - base = await ensurePortalProjectDirectoryBase(fixture); - root = fs.mkdtempSync(path.join(base, 'portal-serve-e2e-')); + /** A preview of `source`, its project in the temp directory, which on a CI runner is another drive than the checkout. */ + const startPreview = async (source: DirectoryPath) => { + const root = canonical(fs.mkdtempSync(path.join(os.tmpdir(), 'portal-serve-e2e-'))); + roots.push(root); - const source = (await new PortalSourceContext(fixture).resolve())._unsafeUnwrap(); + const resolved = (await new PortalSourceContext(source).resolve())._unsafeUnwrap(); const delivered = path.join(root, 'delivered'); fs.mkdirSync(delivered, { recursive: true }); fs.writeFileSync(path.join(delivered, 'typescript.zip'), 'PK typescript'); @@ -39,45 +40,142 @@ const enabled = process.env.APIMATIC_E2E === '1'; new Map([['typescript', '## Installation\n\nInstall it.\n']]), undefined ); - expect(source.generatedPages.missingFrom(artifacts), 'the artifacts back every page').to.be.null; + expect(resolved.generatedPages.missingFrom(artifacts), 'the artifacts back every page').to.be.null; const project = new DirectoryPath(root).join('build'); fs.mkdirSync(project.toString(), { recursive: true }); - const prepared = (await new PortalProjectService().prepare(project, source, artifacts))._unsafeUnwrap(); + const prepared = (await new PortalProjectService().prepare(project, resolved, artifacts))._unsafeUnwrap(); const started = await new PortalDevServerService().start(prepared, await getPort()); if (started.isErr()) { throw new Error(`${started.error.message}\n${started.error.log.split('\n').slice(-20).join('\n')}`); } - server = started.value; + servers.push(started.value); + return { project, server: started.value }; + }; + + /** + * A copy of the fixture as `edit` leaves it, for a test that edits what its preview reads, reached through a link + * to it, as a user's folder may be and macOS's temp directory is. + */ + const linkedCopy = (edit: (source: DirectoryPath) => void = () => undefined): DirectoryPath => { + const root = canonical(fs.mkdtempSync(path.join(os.tmpdir(), 'portal-serve-source-'))); + roots.push(root); + const copy = path.join(root, 'copy'); + fs.cpSync(fixture.toString(), copy, { recursive: true }); + edit(new DirectoryPath(copy)); + fs.symlinkSync(copy, path.join(root, 'src'), 'junction'); + return new DirectoryPath(root).join('src'); + }; + + let checkout: { project: DirectoryPath; server: PortalDevServer }; + let linked: { source: DirectoryPath; server: PortalDevServer }; + + before(async () => { + checkout = await startPreview(fixture); + const source = linkedCopy(); + linked = { source, server: (await startPreview(source)).server }; }); after(async () => { - await server?.stop(); - if (root !== undefined) fs.rmSync(root, { recursive: true, force: true }); - if (base !== undefined) await removePortalProjectDirectoryBase(base); + for (const server of servers) await server.stop(); + roots.forEach((root) => fs.rmSync(root, { recursive: true, force: true })); }); + // Under `vite dev` a page that is not there is answered 200 too, so a Markdown twin is told by its type. + const servesMarkdown = async (from: PortalDevServer, address: string): Promise => { + const response = await globalThis.fetch(`${from.url.toString()}${address}`); + await response.arrayBuffer(); + return response.ok && (response.headers.get('content-type') ?? '').startsWith('text/markdown'); + }; + + // The preview reloads a saved specification in its own time. + const servesMarkdownOnceIt = async (from: PortalDevServer, address: string, expected: boolean): Promise => { + const deadline = Date.now() + 60 * 1000; + let served = await servesMarkdown(from, address); + while (served !== expected && Date.now() < deadline) { + await sleep(250); + served = await servesMarkdown(from, address); + } + return served; + }; + // A missing file answers 200 too, with the fallback page, so the type says what came back. it('serves a dependency file asked for before its importer (apimatic-io#2287)', async () => { - const installed = fs.realpathSync(path.join(root!, 'build', 'node_modules', '@tanstack', 'react-start')); + const installed = fs.realpathSync( + path.join(checkout.project.toString(), 'node_modules', '@tanstack', 'react-start') + ); const entry = path.join(installed, 'dist', 'plugin', 'default-entry', 'client.tsx').split(path.sep).join('/'); - const response = await globalThis.fetch(`${server?.url.toString()}/@fs/${entry}`); + const response = await globalThis.fetch(`${checkout.server.url.toString()}/@fs/${entry}`); expect(response.status, await response.clone().text()).to.equal(200); 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. + const sources: [string, () => { source: DirectoryPath; server: PortalDevServer }][] = [ + ['the checkout', () => ({ source: fixture, server: checkout.server })], + ['a folder reached through a link', () => linked] + ]; + + sources.forEach(([label, preview]) => { + it(`serves a page's static image at its public address, its module asked for before the page, from ${label}`, async () => { + const { source, server } = preview(); + const image = fs + .realpathSync(path.join(source.toString(), 'static', 'images', 'logo.png')) + .split(path.sep) + .join('/'); + + 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'); + }); + }); + + // The dev server names a module by its real path, which its allow list has to name too (apimatic-io#2287). + it('serves a static file by its real path when its folder is reached through a link', async () => { const image = fs - .realpathSync(path.join(fixture.toString(), 'static', 'images', 'logo.png')) + .realpathSync(path.join(linked.source.toString(), 'static', 'images', 'logo.png')) .split(path.sep) .join('/'); - const response = await globalThis.fetch(`${server?.url.toString()}/@fs/${image}`); + const response = await globalThis.fetch(`${linked.server.url.toString()}/@fs/${image}`); expect(response.status).to.equal(200); expect(response.headers.get('content-type')).to.equal('image/png'); }); + + it('leaves out the reference of a specification saved half written, serving the rest, until it is fixed', async () => { + const { source, server } = linked; + const spec = specFile(source); + const written = fs.readFileSync(spec, 'utf8'); + expect(await servesMarkdown(server, reference), 'the reference before the save').to.be.true; + + fs.writeFileSync(spec, written.slice(0, written.length / 2)); + try { + expect(await servesMarkdownOnceIt(server, reference, false), 'the reference while broken').to.be.false; + expect(await servesMarkdown(server, '/authentication.md'), 'a Markdown page while broken').to.be.true; + } finally { + fs.writeFileSync(spec, written); + } + + expect(await servesMarkdownOnceIt(server, reference, true), 'the reference once fixed').to.be.true; + }); + + // A `$ref` below an `$id` is resolved against the `$id`, so the check leaves it to the bundler, which fails on it. + it('starts with a specification the bundler fails on, serving the rest without its reference', async () => { + const source = linkedCopy((copy) => { + const document = JSON.parse(fs.readFileSync(specFile(copy), 'utf8')); + document.components.schemas.Tagged = { $id: 'tagged.json', properties: { tag: { $ref: './tag.yaml' } } }; + fs.writeFileSync(specFile(copy), JSON.stringify(document)); + }); + const { server } = await startPreview(source); + + expect(await servesMarkdown(server, '/authentication.md'), 'a Markdown page').to.be.true; + expect(await servesMarkdown(server, reference), 'the reference').to.be.false; + }); }); diff --git a/test/infrastructure/file-service.test.ts b/test/infrastructure/file-service.test.ts index 81aa79ec0..67944017b 100644 --- a/test/infrastructure/file-service.test.ts +++ b/test/infrastructure/file-service.test.ts @@ -116,6 +116,7 @@ describe('FileService', () => { }); afterEach(() => { + sinon.restore(); fs.rmSync(root.toString(), { recursive: true, force: true }); }); @@ -151,6 +152,28 @@ describe('FileService', () => { expect((await fileService.directorySpelledOnDisk(root, root))?.isEqual(root)).to.be.true; expect(await fileService.directorySpelledOnDisk(root, root.join('static', 'logos'))).to.be.null; }); + + it('answers null for a directory that is not below the root', async () => { + expect(await fileService.directorySpelledOnDisk(root.join('static'), root)).to.be.null; + }); + + it('answers for several files, reading each folder once', async () => { + fs.writeFileSync(path.join(root.toString(), 'static', 'images', 'icon.png'), ''); + const readdir = sinon.spy(fsExtra, 'readdir'); + + const found = await fileService.spellingsOnDisk(root, [ + fileAt('static', 'images', 'Logo.png'), + fileAt('static', 'images', 'icon.png'), + fileAt('static', 'images', 'banner.png') + ]); + + expect(found.map((file) => file?.relativeTo(root) ?? null)).to.deep.equal([ + 'static/images/logo.png', + 'static/images/icon.png', + null + ]); + expect(readdir.callCount).to.equal(3); + }); }); describe('findSymlinks', () => { diff --git a/test/infrastructure/portal-project-service.test.ts b/test/infrastructure/portal-project-service.test.ts index a01f9de45..f6ace380a 100644 --- a/test/infrastructure/portal-project-service.test.ts +++ b/test/infrastructure/portal-project-service.test.ts @@ -266,6 +266,20 @@ describe('PortalProjectService', () => { expect(module).to.contain("from '../../portal.identity.json'"); }); + // The dev server compares a file's real path against its allow list, which names this directory. + it('names the static directory by its real path, when the source is reached through a link', async () => { + const real = path.join(root, 'real'); + fs.mkdirSync(path.join(real, 'static'), { recursive: true }); + fs.symlinkSync(real, path.join(root, 'linked'), 'junction'); + const staticDirectory = new DirectoryPath(root).join('linked', 'static'); + + (await service.prepare(project, sourceFor({ staticDirectory }), NO_ARTIFACTS))._unsafeUnwrap(); + + expect(readConfig().staticDir).to.equal( + path.join(fs.realpathSync.native(real), 'static').split(path.sep).join('/') + ); + }); + it('reports no static directory when the project has none', async () => { (await service.prepare(project, sourceFor(), NO_ARTIFACTS))._unsafeUnwrap(); 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/prompts/portal/serve.test.ts b/test/prompts/portal/serve.test.ts index 7140d40b9..80fc1f294 100644 --- a/test/prompts/portal/serve.test.ts +++ b/test/prompts/portal/serve.test.ts @@ -4,7 +4,10 @@ import sinon from 'sinon'; import { log } from '@clack/prompts'; import { PortalServePrompts } from '../../../src/prompts/portal/serve.js'; import { DirectoryPath } from '../../../src/types/file/directoryPath.js'; +import { FilePath } from '../../../src/types/file/filePath.js'; import { UrlPath } from '../../../src/types/file/urlPath.js'; +import { ContentNotices } from '../../../src/types/portal/content-notices.js'; +import { PortalSourceProblem } from '../../../src/types/portal/portal-source.js'; import { Language } from '../../../src/types/sdk/generate.js'; describe('PortalServePrompts', () => { @@ -49,6 +52,199 @@ describe('PortalServePrompts', () => { 'Mistakes in these files are reported on save, and the preview keeps what it last accepted.' ); }); + + it('says a saved specification updates its reference, and what it cannot read is reported and left out', () => { + expect(printed()).to.match( + /Saving a document in '.*spec' updates its API reference; a mistake is reported on save, and the preview leaves what it cannot read out of the reference until it is fixed\./ + ); + }); + }); + + /** The last line `log[level]` printed for `say`, without its colours. */ + const lastLine = (level: 'warn' | 'message', say: (prompts: PortalServePrompts) => void) => { + const stubs = { + warn: sinon.stub(log, 'warn'), + message: sinon.stub(log, 'message'), + error: sinon.stub(log, 'error') + }; + say(new PortalServePrompts()); + return stripVTControlCharacters(String(stubs[level].lastCall.args[0])); + }; + + // Content keeps what it last accepted; a specification has no earlier version to keep. + it('says the preview shows what it can still read of a refused specification, and that a build would stop', () => { + const problem: PortalSourceProblem = { kind: 'emptySpecDirectory', folders: [] }; + + expect(lastLine('message', (prompts) => prompts.specRejected(problem, new DirectoryPath('src')))).to.match( + /^The preview shows what it can still read until '.*spec' is fixed; a build would stop here\.$/ + ); + }); + + describe('a watch that cannot start, or stops', () => { + const source = new DirectoryPath('src'); + const cases: [string, (prompts: PortalServePrompts) => void, RegExp][] = [ + [ + 'apimatic.json, at the start', + (prompts) => prompts.notWatched('config', 'EMFILE', source), + /^'apimatic\.json' cannot be watched \(EMFILE\), so edits to it need the preview restarted\.$/ + ], + [ + 'apimatic.json, while serving', + (prompts) => prompts.watchFailed('config', 'EPERM', source), + /^'apimatic\.json' is no longer watched \(EPERM\), so edits to it need the preview restarted\.$/ + ], + [ + 'the content, at the start', + (prompts) => prompts.notWatched('content', 'EMFILE', source), + /^'.*content' cannot be watched \(EMFILE\), so a mistake in a page or a 'nav\.json' is only reported when the preview is restarted\.$/ + ], + [ + 'the content, while serving', + (prompts) => prompts.watchFailed('content', 'EPERM', source), + /^'.*content' is no longer watched \(EPERM\), so a mistake in a page or a 'nav\.json' is only reported when the preview is restarted\.$/ + ], + [ + 'the specifications, at the start', + (prompts) => prompts.notWatched('spec', 'EMFILE', source), + /^'.*spec' cannot be watched \(EMFILE\), so a mistake in a specification is only reported when the preview is restarted\.$/ + ], + [ + 'the specifications, while serving', + (prompts) => prompts.watchFailed('spec', 'EPERM', source), + /^'.*spec' is no longer watched \(EPERM\), so a mistake in a specification is only reported when the preview is restarted\.$/ + ] + ]; + + cases.forEach(([label, say, expected]) => { + it(`says what is no longer reported on save for ${label}`, () => { + expect(lastLine('warn', say)).to.match(expected); + }); + }); + }); + + describe('a change it accepted that could not be applied to the preview', () => { + const source = new DirectoryPath('src'); + + it('names apimatic.json, and why', () => { + expect(lastLine('warn', (prompts) => prompts.notApplied('config', 'EACCES', source))).to.equal( + "The changes to 'apimatic.json' could not be applied to the preview: EACCES" + ); + }); + + it('names the content directory, and why', () => { + expect(lastLine('warn', (prompts) => prompts.notApplied('content', 'EBUSY', source))).to.match( + /^The changes to '.*content' could not be applied to the preview: EBUSY$/ + ); + }); + }); + + describe('a change to which documents are read from the spec directory', () => { + const document = (name: string) => FilePath.resolve(new DirectoryPath('src', 'spec'), name); + + /** The warning, then the advice that follows it. */ + const said = (added: string[], removed: string[]) => { + const warn = sinon.stub(log, 'warn'); + const message = sinon.stub(log, 'message'); + try { + new PortalServePrompts().specsShown( + { fixed: false, change: { added: added.map(document), removed: removed.map(document) } }, + new DirectoryPath('src') + ); + return [warn, message].map((stub) => stripVTControlCharacters(String(stub.firstCall.args[0]))); + } finally { + sinon.restore(); + } + }; + + // Whether a file was added or removed, or `APIMATIC-META.json` changed which folders merge. + it('names each document now read and no longer read, and that a restart matches them', () => { + const [warning, advice] = said(['orders.json'], ['pets.json']); + + expect(warning).to.match( + /^The documents read from '.*spec' are not the ones the preview started with: 'orders\.json' is now read, and 'pets\.json' is no longer read\.$/ + ); + expect(advice).to.equal( + 'Restart the preview to match them; until then it shows the ones it started with that are still there.' + ); + }); + + it('lists several documents, and says only what changed', () => { + expect(said(['a.json', 'b.json'], [])[0]).to.match(/: 'a\.json' and 'b\.json' are now read\.$/); + expect(said([], ['a.json', 'b.json', 'c.json'])[0]).to.match( + /: 'a\.json', 'b\.json' and 'c\.json' are no longer read\.$/ + ); + }); + + it('says when the documents are the ones it started with again, which needs no restart', () => { + const success = sinon.stub(log, 'success'); + const warn = sinon.stub(log, 'warn'); + + new PortalServePrompts().specsShown( + { fixed: false, change: { added: [], removed: [] } }, + new DirectoryPath('src') + ); + + expect(warn.called).to.be.false; + expect(stripVTControlCharacters(String(success.firstCall.args[0]))).to.match( + /^The documents read from '.*spec' are the ones the preview started with again; it no longer needs a restart\.$/ + ); + }); + + // Each folder `APIMATIC-META.json` merges may hold an `openapi.yaml`. + it('names a document in a folder by its path from the spec directory', () => { + expect(said(['payments/openapi.yaml'], ['orders/openapi.yaml'])[0]).to.match( + /: 'payments\/openapi\.yaml' is now read, and 'orders\/openapi\.yaml' is no longer read\.$/ + ); + }); + }); + + describe('a save the preview accepts', () => { + const NO_NOTICES: ContentNotices = { + hiddenPages: [], + ignoredNavigationFiles: [], + sharedTabNames: [], + noTabsListed: undefined, + unseenHomeTitle: undefined + }; + + /** What `show` says succeeded, without its colours. */ + const succeeded = (show: (prompts: PortalServePrompts, source: DirectoryPath) => void) => { + const success = sinon.stub(log, 'success'); + try { + show(new PortalServePrompts(), new DirectoryPath('src')); + return success.getCalls().map((call) => stripVTControlCharacters(String(call.args[0]))); + } finally { + success.restore(); + } + }; + + it('says the content or the specifications are fixed, when the save fixes one refused', () => { + const content = succeeded((prompts, source) => + prompts.contentShown({ fixed: true, notices: NO_NOTICES }, source) + ); + const specs = succeeded((prompts, source) => prompts.specsShown({ fixed: true, change: null }, source)); + + expect(content).to.have.length(1); + expect(content[0]).to.match(/^'.*content' is fixed; a build would accept it again\.$/); + expect(specs).to.have.length(1); + expect(specs[0]).to.match(/^'.*spec' is fixed; a build would accept it again\.$/); + }); + + it('says nothing of a save that fixes nothing', () => { + const content = succeeded((prompts, source) => + prompts.contentShown({ fixed: false, notices: NO_NOTICES }, source) + ); + const specs = succeeded((prompts, source) => prompts.specsShown({ fixed: false, change: null }, source)); + + expect([...content, ...specs]).to.be.empty; + }); + + it('says the changes to apimatic.json were applied only when the preview says to', () => { + expect(succeeded((prompts) => prompts.configShown(true))).to.deep.equal([ + "Applied the changes to 'apimatic.json'." + ]); + expect(succeeded((prompts) => prompts.configShown(false))).to.be.empty; + }); }); describe('an edit that needs artifacts the preview was started without', () => { diff --git a/test/prompts/portal/source.test.ts b/test/prompts/portal/source.test.ts index 2af4be54c..62fe5069f 100644 --- a/test/prompts/portal/source.test.ts +++ b/test/prompts/portal/source.test.ts @@ -14,7 +14,7 @@ import { reportUnseenHomeTitle } from '../../../src/prompts/portal/source.js'; import { Endpoint } from '../../../src/types/portal/endpoint.js'; -import { ContentProblem } from '../../../src/types/portal/portal-source.js'; +import { ContentProblem, UnreadableSpecReason } from '../../../src/types/portal/portal-source.js'; import { DirectoryPath } from '../../../src/types/file/directoryPath.js'; import { FileName } from '../../../src/types/file/fileName.js'; import { FilePath } from '../../../src/types/file/filePath.js'; @@ -219,9 +219,9 @@ describe('reportSourceProblem', () => { it('names a document it cannot read by its folder', () => { const file = new FilePath(spec.join('orders'), new FileName('openapi.yaml')); - reportSourceProblem({ kind: 'unreadableSpec', file }, source); + reportSourceProblem({ kind: 'unreadableSpec', file, referencedBy: null, reason: { kind: 'empty' } }, source); - expect(printed()).to.equal("'spec/orders/openapi.yaml' could not be read as JSON or YAML."); + expect(printed()).to.equal("'spec/orders/openapi.yaml' could not be read: it is empty."); }); it('names a metadata file it cannot read, which is JSON only', () => { @@ -314,6 +314,97 @@ describe('reportSourceProblem', () => { }); }); + describe('a file a specification is read from', () => { + const spec = source.join('spec'); + const api = new FilePath(spec, new FileName('api.yaml')); + const pet = new FilePath(spec.join('schemas'), new FileName('Pet.json')); + + it('names a document it cannot read, and why, in the words of the parser that refused it', () => { + const message = + 'Flow sequence in block collection must be sufficiently indented and end with a ] at line 3, column 1'; + + reportSourceProblem( + { kind: 'unreadableSpec', file: api, referencedBy: null, reason: { kind: 'invalid', message } }, + source + ); + + expect(printed()).to.equal(`'spec/api.yaml' could not be read: ${message}.`); + }); + + it('names the file that refers to one it cannot read', () => { + const message = "Expected ',' or '}' after property value in JSON at position 13 (line 3 column 3)"; + + reportSourceProblem( + { kind: 'unreadableSpec', file: pet, referencedBy: api, reason: { kind: 'invalid', message } }, + source + ); + + expect(printed()).to.equal( + `'spec/schemas/Pet.json', which 'spec/api.yaml' refers to, could not be read: ${message}.` + ); + }); + + const reasons: [UnreadableSpecReason, string][] = [ + [{ kind: 'empty' }, 'it is empty'], + [{ kind: 'noValue' }, 'it holds no value'], + [{ kind: 'noKey' }, 'it is not JSON, and the portal reads YAML only when it holds a key'], + [{ kind: 'gone' }, 'it is no longer there'], + [{ kind: 'folder' }, 'it is a folder'], + [{ kind: 'unopened', message: 'EACCES: permission denied' }, 'EACCES: permission denied'] + ]; + + reasons.forEach(([reason, said]) => { + it(`says why a file could not be read, when it is ${reason.kind}`, () => { + reportSourceProblem({ kind: 'unreadableSpec', file: api, referencedBy: null, reason }, source); + + expect(printed()).to.equal(`'spec/api.yaml' could not be read: ${said}.`); + }); + }); + + it('names a file referred to that is not there, and where a reference is resolved from', () => { + reportSourceProblem( + { kind: 'missingSpecReference', missing: { file: pet, foundAs: null }, referencedBy: api }, + source + ); + + expect(printed()).to.equal( + "'spec/api.yaml' refers to 'spec/schemas/Pet.json', but there is no such file.\n" + + "A '$ref' names a file from the folder of the file it is written in." + ); + }); + + it('names a file referred to that is there in another case, as it is spelt', () => { + const lowerCase = new FilePath(spec.join('schemas'), new FileName('pet.json')); + + reportSourceProblem( + { kind: 'missingSpecReference', missing: { file: lowerCase, foundAs: pet }, referencedBy: api }, + source + ); + + expect(printed()).to.equal( + "'spec/api.yaml' refers to 'spec/schemas/pet.json', but the file is spelt 'spec/schemas/Pet.json' on disk." + ); + }); + + const PART_NAMED = + "A '$ref' names a part of the file it is in as '#/components/schemas/Pet', and a part of another as " + + "'./pets.yaml#/Pet'."; + + it('names the file a $ref the build cannot resolve is in, and how to name a part of a file', () => { + reportSourceProblem({ kind: 'unresolvableSpecReference', reference: { file: api, ref: '#pet' } }, source); + + expect(printed()).to.equal( + `'spec/api.yaml' has the '$ref' '#pet', which the portal cannot resolve.\n${PART_NAMED}` + ); + }); + + it('says a $ref is empty rather than quoting it', () => { + reportSourceProblem({ kind: 'unresolvableSpecReference', reference: { file: pet, ref: '' } }, source); + + expect(printed()).to.equal(`'spec/schemas/Pet.json' has an empty '$ref'.\n${PART_NAMED}`); + }); + }); + describe("a page at an address kept for the portal's own pages", () => { const content = source.join('content'); 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/resources/portal-inputs/default/src/content/guides/components.mdx b/test/resources/portal-inputs/default/src/content/guides/components.mdx new file mode 100644 index 000000000..e069b5136 --- /dev/null +++ b/test/resources/portal-inputs/default/src/content/guides/components.mdx @@ -0,0 +1,132 @@ +--- +title: Components +description: Every component an author can write without importing it. +--- + +Inline contents + +## Callouts + +Default callout body. + +Warn callout body. + +Error callout body. + +Success callout body. + +Idea callout body. + + + Composed title + Composed callout body. + + +## Cards + + + + + Plain card body. + + +## Code + +```ts title="client.ts" lineNumbers +const client = new Client(); // [!code highlight] +``` + +```python tab="Python" +print("python tab body") +``` + +```ts tab="TypeScript" +console.log('typescript tab body'); +``` + +```npm +npm install calc +``` + + + + Composed first + Composed second + + + +```bash +echo composed-first +``` + + + + +```bash +echo composed-second +``` + + + + +## Tabs + + + First tab body. + Second tab body. + + + + Left tab body. + Right tab body. + + +## Steps + + + First step body. + Second step body. + + +## Accordions + + + Accordion body. + Second accordion body. + + + + Multiple one body. + Multiple two body. + + +## Files + + + + + + + + + + + +## Type table + + + +## Include + +./intro.md 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', () => { diff --git a/test/types/portal-source-context.test.ts b/test/types/portal-source-context.test.ts index b05b2843b..7b55d3e59 100644 --- a/test/types/portal-source-context.test.ts +++ b/test/types/portal-source-context.test.ts @@ -2,6 +2,7 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; import { Buffer } from 'node:buffer'; +import { pathToFileURL } from 'node:url'; import Ajv from 'ajv'; import { expect } from 'chai'; import { Result } from 'neverthrow'; @@ -24,6 +25,7 @@ import { FilePath } from '../../src/types/file/filePath'; import { PLUGIN_SECTION, SDK_SECTION } from '../../src/types/portal/generated-pages'; import { TabEntry } from '../../src/types/portal/portal-navigation'; import { ZipService } from '../../src/infrastructure/zip-service'; +import { bundleSpec } from '../../portal-template/src/lib/openapi-bundle.server'; const OPENAPI = JSON.stringify({ openapi: '3.0.0', info: { title: 'Calc', version: '1' }, paths: {} }); @@ -379,6 +381,16 @@ describe('PortalSourceContext', () => { }); }); + /** A file the specifications could not be read from: the kind, the file, and the file that refers to it. */ + const specProblem = (problem: PortalSourceProblem): [string, string, string | null] => { + if (problem.kind !== 'unreadableSpec' && problem.kind !== 'missingSpecReference') { + throw new Error(`expected a file the specifications are read from, got ${JSON.stringify(problem)}`); + } + const relative = (file: FilePath) => file.relativeTo(new DirectoryPath(root)); + const file = problem.kind === 'missingSpecReference' ? problem.missing.file : problem.file; + return [problem.kind, relative(file), problem.referencedBy === null ? null : relative(problem.referencedBy)]; + }; + describe('resolveSpecs', () => { const resolveSpecs = () => new PortalSourceContext(new DirectoryPath(root)).resolveSpecs(); @@ -414,6 +426,7 @@ describe('PortalSourceContext', () => { const source = (await resolve())._unsafeUnwrap(); expect(source.specs.map((spec) => spec.slug)).to.deep.equal(['a', 'b', 'c']); + expect(source.specDirectory.toString()).to.equal(path.join(root, 'spec')); }); it('ignores documents that carry no version key', async () => { @@ -489,19 +502,75 @@ describe('PortalSourceContext', () => { ]); }); - /** The document the problem names as unreadable, relative to the source directory. */ - const unreadable = async () => { + it('reports a document it cannot parse, with what the parser said', async () => { + write('spec/broken.json', '{ not json'); + + const problem = (await resolve())._unsafeUnwrapErr(); + + expect(specProblem(problem)).to.deep.equal(['unreadableSpec', 'spec/broken.json', null]); + expect( + problem.kind === 'unreadableSpec' && problem.reason.kind === 'invalid' && problem.reason.message + ).to.contain('JSON'); + }); + + // As the portal's bundler reads it: by what it holds. + it('reads a document whatever its extension says', async () => { + write('spec/api.json', 'openapi: 3.0.0\ninfo:\n title: Calc\n version: "1"\npaths: {}\n'); + + expect((await resolve())._unsafeUnwrap().specs.map((spec) => spec.slug)).to.deep.equal(['api']); + }); + + // The build reads past a file that is no API, and never bundles it. + it('skips a file holding nothing, only comments, or no key beside a document', async () => { + write('spec/api.json', OPENAPI); + write('spec/orders.yaml', ''); + write('spec/notes.yaml', '# orders: to come\n'); + write('spec/states.yaml', '- active\n- inactive\n'); + + expect((await resolve())._unsafeUnwrap().specs.map((spec) => spec.slug)).to.deep.equal(['api']); + }); + + // As a document caught empty mid-save is; with nothing else to build from, it is not one to convert. + it('reports a document left empty when every document there holds nothing', async () => { + write('spec/api.yaml', ''); + write('spec/notes.yaml', '# to come\n'); + const problem = (await resolve())._unsafeUnwrapErr(); - if (problem.kind !== 'unreadableSpec') { - throw new Error(`expected an unreadable document, got '${problem.kind}'`); + + expect(specProblem(problem)).to.deep.equal(['unreadableSpec', 'spec/api.yaml', null]); + expect(problem.kind === 'unreadableSpec' && problem.reason).to.deep.equal({ kind: 'empty' }); + }); + + // As an editor that saves by renaming over the file leaves it for a moment. + it('reports a document gone since it was listed as one it cannot read, rather than leaving it out', async () => { + write('spec/api.json', OPENAPI); + sinon.stub(FileService.prototype, 'getContents').rejects(new Error('ENOENT: no such file or directory')); + sinon.stub(FileService.prototype, 'fileExists').resolves(false); + + try { + const problem = (await new PortalSourceContext(new DirectoryPath(root)).resolveSpecs())._unsafeUnwrapErr(); + + expect(specProblem(problem)).to.deep.equal(['unreadableSpec', 'spec/api.json', null]); + expect(problem.kind === 'unreadableSpec' && problem.reason).to.deep.equal({ kind: 'gone' }); + } finally { + sinon.restore(); } - return relative(problem.file); - }; + }); - it('reports a document it cannot parse', async () => { - write('spec/broken.json', '{ not json'); + it('reports a document it cannot open with what the file system said', async () => { + write('spec/api.json', OPENAPI); + sinon.stub(FileService.prototype, 'getContents').rejects(new Error('EACCES: permission denied')); + + try { + const problem = (await new PortalSourceContext(new DirectoryPath(root)).resolveSpecs())._unsafeUnwrapErr(); - expect(await unreadable()).to.equal('spec/broken.json'); + expect(problem.kind === 'unreadableSpec' && problem.reason).to.deep.equal({ + kind: 'unopened', + message: 'EACCES: permission denied' + }); + } finally { + sinon.restore(); + } }); it('reports an empty or absent spec directory', async () => { @@ -558,8 +627,7 @@ describe('PortalSourceContext', () => { paths: { '/health': { get: {} }, '/pets': { $ref: './paths/pets.yaml' }, - '/owners': { $ref: 'shared.json#/items/owners' }, - '/missing': { $ref: './nowhere.yaml' } + '/owners': { $ref: 'shared.json#/items/owners' } } }) ); @@ -832,7 +900,27 @@ describe('PortalSourceContext', () => { merging(); write('spec/alpha/openapi.json', '{ not json'); - expect(await unreadable()).to.equal('spec/alpha/openapi.json'); + expect(specProblem((await resolve())._unsafeUnwrapErr())).to.deep.equal([ + 'unreadableSpec', + 'spec/alpha/openapi.json', + null + ]); + }); + + it('skips a file holding nothing beside the definition of a folder, and reports one alone', async () => { + merging(); + write('spec/alpha/openapi.json', OPENAPI); + write('spec/alpha/notes.yaml', ''); + + expect(await read()).to.deep.equal([['alpha', 'spec/alpha/openapi.json']]); + + fs.rmSync(path.join(root, 'spec', 'alpha', 'openapi.json')); + + expect(specProblem((await resolve())._unsafeUnwrapErr())).to.deep.equal([ + 'unreadableSpec', + 'spec/alpha/notes.yaml', + null + ]); }); it('reports a metadata file it cannot parse', async () => { @@ -903,6 +991,341 @@ describe('PortalSourceContext', () => { }); }); + describe('the files a specification refers to', () => { + beforeEach(() => writeConfig({ site: { name: 'Calc' } })); + afterEach(() => sinon.restore()); + + /** `spec/api.json`, whose schemas are the given ones. */ + const writeSpec = (schemas: Record, extra: Record = {}) => + write( + 'spec/api.json', + JSON.stringify({ + openapi: '3.0.0', + info: { title: 'Calc', version: '1' }, + paths: {}, + components: { schemas }, + ...extra + }) + ); + + it('refuses a file a specification refers to that is not there, naming the file that refers to it', async () => { + writeSpec({ Pet: { $ref: './schemas/Pet.yaml' } }); + + expect(specProblem((await resolve())._unsafeUnwrapErr())).to.deep.equal([ + 'missingSpecReference', + 'spec/schemas/Pet.yaml', + 'spec/api.json' + ]); + }); + + // As a file system that matches case, which the build may run on, would miss it. + it('names the file a missing reference is spelt as on disk, in another case', async () => { + writeSpec({ Pet: { $ref: './schemas/pet.yaml' } }); + write('spec/schemas/Pet.yaml', 'type: object\n'); + const named = sinon.match((file: FilePath) => file.toString() === path.join(root, 'spec', 'schemas', 'pet.yaml')); + sinon + .stub(FileService.prototype, 'getContents') + .callThrough() + .withArgs(named) + .rejects(new Error('ENOENT: no such file or directory')); + sinon.stub(FileService.prototype, 'fileExists').callThrough().withArgs(named).resolves(false); + + const problem = (await resolve())._unsafeUnwrapErr(); + + expect(specProblem(problem)).to.deep.equal(['missingSpecReference', 'spec/schemas/pet.yaml', 'spec/api.json']); + expect( + problem.kind === 'missingSpecReference' && problem.missing.foundAs?.relativeTo(new DirectoryPath(root)) + ).to.equal('spec/schemas/Pet.yaml'); + }); + + // Read here on a file system that ignores case, as Windows and macOS do; a build on Linux would miss it. + it('refuses a file or a folder named in another case than on disk, wherever it reads', async () => { + write('spec/schemas/Pet.yaml', 'type: object\n'); + const foundAs = async () => { + const problem = (await resolve())._unsafeUnwrapErr(); + expect(specProblem(problem)[0]).to.equal('missingSpecReference'); + return problem.kind === 'missingSpecReference' + ? problem.missing.foundAs?.relativeTo(new DirectoryPath(root)) + : null; + }; + + writeSpec({ Pet: { $ref: './schemas/pet.yaml' } }); + expect(await foundAs()).to.equal('spec/schemas/Pet.yaml'); + + writeSpec({ Pet: { $ref: './Schemas/Pet.yaml' } }); + expect(await foundAs()).to.equal('spec/schemas/Pet.yaml'); + }); + + // As a name stored in another Unicode form, or a folder that cannot be listed, is. + it('accepts a file it reads but finds in no listing, rather than calling it misspelt', async () => { + writeSpec({ Pet: { $ref: './schemas/Pet.yaml' } }); + write('spec/schemas/Pet.yaml', 'type: object\n'); + sinon + .stub(FileService.prototype, 'spellingsOnDisk') + .callsFake(async (_root: DirectoryPath, files: FilePath[]) => files.map(() => null)); + + expect((await resolve()).isOk()).to.be.true; + }); + + // Its folders above the source directory are where the build runs, not the user's to spell. + it('accepts a file outside the source directory', async () => { + const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'portal-shared-')); + try { + fs.writeFileSync(path.join(outside, 'Pet.yaml'), 'type: object\n'); + writeSpec({ Pet: { $ref: path.relative(path.join(root, 'spec'), path.join(outside, 'Pet.yaml')) } }); + + expect((await resolve()).isOk()).to.be.true; + } finally { + fs.rmSync(outside, { recursive: true, force: true }); + } + }); + + it('refuses a file referred to in turn that does not parse, naming the file that refers to it', async () => { + writeSpec({ Pet: { $ref: './schemas/Pet.yaml' } }); + write('spec/schemas/Pet.yaml', 'type: object\nproperties:\n owner:\n $ref: ./Owner.yaml\n'); + write('spec/schemas/Owner.yaml', 'type: object\nrequired: [name\n'); + + const problem = (await resolve())._unsafeUnwrapErr(); + + expect(specProblem(problem)).to.deep.equal([ + 'unreadableSpec', + 'spec/schemas/Owner.yaml', + 'spec/schemas/Pet.yaml' + ]); + expect(problem.kind === 'unreadableSpec' && problem.reason.kind === 'invalid' && problem.reason.message).to.match( + /line \d+, column \d+$/ + ); + }); + + it('says a reference that names a folder names one', async () => { + writeSpec({ Pet: { $ref: './schemas/' } }); + write('spec/schemas/Pet.yaml', 'type: object\n'); + + const problem = (await resolve())._unsafeUnwrapErr(); + + expect(specProblem(problem)).to.deep.equal(['unreadableSpec', 'spec/schemas', 'spec/api.json']); + expect(problem.kind === 'unreadableSpec' && problem.reason).to.deep.equal({ kind: 'folder' }); + }); + + it('refuses an empty reference, naming the file it is written in', async () => { + writeSpec({ Pet: { $ref: './schemas/Pet.yaml' } }); + write('spec/schemas/Pet.yaml', 'properties:\n owner:\n $ref: ""\n'); + + const problem = (await resolve())._unsafeUnwrapErr(); + if (problem.kind !== 'unresolvableSpecReference') { + expect.fail(`expected a $ref the build cannot resolve, got '${problem.kind}'`); + } + + const { file, ref } = problem.reference; + expect([file.relativeTo(new DirectoryPath(root)), ref]).to.deep.equal(['spec/schemas/Pet.yaml', '']); + }); + + it('follows a reference out of the spec directory, as the build does', async () => { + writeSpec({ Pet: { $ref: '../shared/Pet.yaml' } }); + + expect(specProblem((await resolve())._unsafeUnwrapErr())).to.deep.equal([ + 'missingSpecReference', + 'shared/Pet.yaml', + 'spec/api.json' + ]); + + write('shared/Pet.yaml', 'type: object\n'); + expect((await resolve()).isOk()).to.be.true; + }); + + it('reads each file once, however often it is referred to, and ends at a cycle', async () => { + writeSpec({ Pet: { $ref: './schemas/Pet.yaml' }, Pets: { items: { $ref: './schemas/Pet.yaml#/Pet' } } }); + write('spec/schemas/Pet.yaml', 'Pet:\n properties:\n owner:\n $ref: ./Owner.yaml\n'); + write('spec/schemas/Owner.yaml', 'properties:\n pets:\n $ref: ./Pet.yaml\n'); + const reads = sinon.spy(FileService.prototype, 'getContents'); + + expect((await resolve()).isOk()).to.be.true; + const specFiles = reads.args + .map(([file]) => file.relativeTo(new DirectoryPath(root))) + .filter((file) => file.startsWith('spec/')); + expect(specFiles.sort()).to.deep.equal(['spec/api.json', 'spec/schemas/Owner.yaml', 'spec/schemas/Pet.yaml']); + }); + + // Held to the bundler itself, since a file refused here would otherwise have built. + describe('as the portal bundles it', () => { + beforeEach(() => sinon.stub(globalThis.console, 'warn')); + + const bundles = () => + bundleSpec(path.join(root, 'spec', 'api.json')).then( + () => true, + () => false + ); + + /** `spec/api.json`, whose schema `Pet` is the file `schemas/` holding `contents`. */ + const writeReferenced = (name: string, contents: string) => { + writeSpec({ Pet: { $ref: `./schemas/${name}` } }); + write(`spec/schemas/${name}`, contents); + }; + + // `empty`: the bundler builds, with the reference pointing at nothing, so the page renders without it. + const BUNDLED = { + builds: 'as the bundler builds it', + fails: 'as the bundler fails on it', + empty: 'which the bundler reads as nothing' + }; + + const cases: [string, () => void, keyof typeof BUNDLED][] = [ + ['a file that is there', () => writeReferenced('Pet.yaml', 'type: object\n'), 'builds'], + ['a file that is not there', () => writeSpec({ Pet: { $ref: './schemas/Pet.yaml' } }), 'fails'], + ['a file of half-written YAML', () => writeReferenced('Pet.yaml', 'type: object\nrequired: [name\n'), 'fails'], + ['a file of half-written JSON', () => writeReferenced('Pet.json', '{ "type": "obj'), 'empty'], + ['a JSON file holding YAML', () => writeReferenced('Pet.json', 'type: object\n'), 'builds'], + ['a YAML file that opens as JSON does', () => writeReferenced('Pet.yaml', '{ type: object }\n'), 'empty'], + [ + 'a YAML file that opens as JSON does only past its first 50 characters', + () => writeReferenced('Pet.yaml', `${' '.repeat(50)}{ type: object }\n`), + 'builds' + ], + ['a file left empty', () => writeReferenced('Pet.yaml', ''), 'empty'], + ['a file holding only a comment', () => writeReferenced('Pet.yaml', '# type: object\n'), 'empty'], + [ + 'a file named in a file that is a list', + () => { + writeSpec({ Pet: { allOf: { $ref: './schemas/parts.yaml' } } }); + write('spec/schemas/parts.yaml', '- $ref: ./Base.yaml\n'); + }, + 'fails' + ], + [ + 'a file named in example data', + () => writeSpec({ Pet: { type: 'object', example: { $ref: './examples/pet.json' } } }), + 'fails' + ], + [ + 'a file named beside a reference, which is not read', + () => { + writeSpec({ Pet: { $ref: './Pet.yaml', properties: { owner: { $ref: './Owner.yaml' } } } }); + write('spec/Pet.yaml', 'type: object\n'); + }, + 'builds' + ], + [ + 'two files that refer to each other', + () => { + writeSpec({ Pet: { $ref: './Pet.yaml' } }); + write('spec/Pet.yaml', 'properties:\n owner:\n $ref: ./Owner.yaml\n'); + write('spec/Owner.yaml', 'properties:\n pet:\n $ref: ./Pet.yaml\n'); + }, + 'builds' + ], + [ + 'a file named from the folder of the file that refers to it', + () => { + writeReferenced('Pet.yaml', 'properties:\n owner:\n $ref: ./Owner.yaml\n'); + write('spec/schemas/Owner.yaml', 'type: object\n'); + }, + 'builds' + ], + [ + 'a file named in a part of a file that no reference points into', + () => { + writeSpec({ Pet: { $ref: './schemas/models.yaml#/Pet' } }); + write('spec/schemas/models.yaml', 'Pet:\n type: object\nOwner:\n $ref: ./Owner.yaml\n'); + }, + 'fails' + ], + [ + 'a file outside the spec directory', + () => { + writeSpec({ Pet: { $ref: '../shared/Pet.yaml' } }); + write('shared/Pet.yaml', 'type: object\n'); + }, + 'builds' + ], + [ + 'a name written with a percent escape, which is not decoded', + () => { + writeSpec({ Pet: { $ref: './my%20pet.yaml' } }); + write('spec/my pet.yaml', 'type: object\n'); + }, + 'fails' + ], + [ + 'a schema named by its $id', + () => writeSpec({ Pet: { $id: 'pet.json', type: 'object' }, Pets: { items: { $ref: 'pet.json' } } }), + 'builds' + ], + [ + 'a schema named by its $id from another file', + () => { + writeSpec({ Pet: { $id: 'pet.json', type: 'object' }, Owner: { $ref: './schemas/Owner.yaml' } }); + write('spec/schemas/Owner.yaml', 'properties:\n pet:\n $ref: pet.json\n'); + }, + 'builds' + ], + [ + 'a file named only under the keys the bundler writes', + () => writeSpec({}, { 'x-ext': { abc123: { $ref: './embedded.yaml' } } }), + 'builds' + ], + ['an empty reference', () => writeSpec({ Pet: { $ref: '' } }), 'fails'], + [ + 'an empty reference in a file named', + () => writeReferenced('Pet.yaml', 'properties:\n owner:\n $ref: ""\n'), + 'fails' + ], + ['a bare # in the document bundled', () => writeSpec({ Pet: { $ref: '#' } }), 'fails'], + [ + 'a schema named by its $anchor in the document bundled', + () => writeSpec({ Pet: { $anchor: 'pet', type: 'object' }, Pets: { items: { $ref: '#pet' } } }), + 'fails' + ], + [ + 'a bare # or an anchor in a file named, which the bundler turns into pointers', + () => writeReferenced('Pet.yaml', 'properties:\n self:\n $ref: "#"\n tag:\n $ref: "#tag"\n'), + 'builds' + ], + [ + 'a file named by a file: URL, which the bundler reads as a path', + () => { + writeSpec({ Pet: { $ref: pathToFileURL(path.join(root, 'spec', 'Pet.yaml')).href } }); + write('spec/Pet.yaml', 'type: object\n'); + }, + 'fails' + ], + ['a name with another scheme', () => writeSpec({ Pet: { $ref: 'urn:pet' } }), 'fails'], + [ + 'a folder', + () => { + writeSpec({ Pet: { $ref: './schemas' } }); + write('spec/schemas/Pet.yaml', 'type: object\n'); + }, + 'fails' + ], + [ + 'a part of a file that is not there, which the page renders without', + () => { + writeSpec({ Pet: { $ref: './Pet.yaml#/nope' } }); + write('spec/Pet.yaml', 'type: object\n'); + }, + 'builds' + ] + ]; + + cases.forEach(([label, arrange, bundled]) => { + it(`${bundled === 'builds' ? 'accepts' : 'refuses'} ${label}, ${BUNDLED[bundled]}`, async () => { + arrange(); + + expect(await bundles(), 'the bundler').to.equal(bundled !== 'fails'); + expect((await resolve()).isOk(), 'the check').to.equal(bundled === 'builds'); + }); + }); + + // Resolved against the $id, which names no folder of the specification, so the check leaves it to the build. + it('accepts a file named below an $id, which the bundler fails on', async () => { + writeSpec({ Pet: { $id: 'pet.json', properties: { tag: { $ref: './tag.yaml' } } } }); + + expect(await bundles(), 'the bundler').to.be.false; + expect((await resolve()).isOk(), 'the check').to.be.true; + }); + }); + }); + describe('optional directories', () => { beforeEach(() => { writeConfig({ site: { name: 'Calc' } }); diff --git a/test/types/portal/openapi-document.test.ts b/test/types/portal/openapi-document.test.ts index 1ad05d94a..f259fb78e 100644 --- a/test/types/portal/openapi-document.test.ts +++ b/test/types/portal/openapi-document.test.ts @@ -1,32 +1,48 @@ import { expect } from 'chai'; import path from 'path'; import { OpenApiDocument, SpecFormat } from '../../../src/types/portal/openapi-document'; -import { FileName } from '../../../src/types/file/fileName'; import { DirectoryPath } from '../../../src/types/file/directoryPath'; -const JSON_FILE = new FileName('spec.json'); -const YAML_FILE = new FileName('spec.yaml'); const BYTE_ORDER_MARK = ''; describe('OpenApiDocument', () => { - const read = (contents: string, fileName = JSON_FILE): OpenApiDocument => { - const document = OpenApiDocument.parse(fileName, contents); - expect(document, `unparsable: ${contents}`).to.not.be.undefined; - return document as OpenApiDocument; + const read = (contents: string): OpenApiDocument => { + const document = OpenApiDocument.parse(contents); + expect(document.isOk(), `unparsable: ${contents}`).to.be.true; + return document._unsafeUnwrap(); }; const readJson = (document: unknown, prefix = '') => read(prefix + JSON.stringify(document)); const withInfo = (info: Record) => readJson({ openapi: '3.0.0', info, paths: {} }); + const refused = (contents: string) => OpenApiDocument.parse(contents)._unsafeUnwrapErr(); describe('parse', () => { - it('reads JSON by the .json extension and YAML by any other', () => { + it('reads JSON, and YAML, by what the text holds', () => { expect(readJson({ openapi: '3.1.0' }).format()).to.deep.equal({ supported: true }); - expect(read('openapi: 3.1.0\n', YAML_FILE).format()).to.deep.equal({ supported: true }); + expect(read('openapi: 3.1.0\n').format()).to.deep.equal({ supported: true }); }); - it('is undefined for text neither parser accepts', () => { - expect(OpenApiDocument.parse(JSON_FILE, '{ not json')).to.be.undefined; - expect(OpenApiDocument.parse(YAML_FILE, 'key: [unclosed')).to.be.undefined; + // Matched loosely: the words are the parser's own, and change with its version. + it('fails with what the parser said, on one line, saying where it stopped', () => { + const said = (contents: string) => { + const failure = refused(contents); + return failure.kind === 'invalid' ? failure.message : `not invalid but ${failure.kind}`; + }; + const yaml = said('openapi: 3.0.0\ninfo:\n title: [unclosed\n'); + const json = said('{\n "a": 1\n "b": 2\n}'); + const quoting = said('[1,\n2,\n,3]'); + + expect(yaml).to.match(/at line 4, column 1$/); + expect(json).to.contain('line 3 column 3'); + [yaml, json, quoting].forEach((message) => expect(message).to.not.contain('\n')); + }); + + // The portal's bundler reads these as nothing, so a reference to one would be empty. + it('fails on text the bundler reads as nothing, saying which kind it is', () => { + expect(refused(' \n')).to.deep.equal({ kind: 'empty' }); + expect(refused('# only a comment: here\n')).to.deep.equal({ kind: 'noValue' }); + expect(refused('{ type: object }').kind).to.equal('invalid'); + expect(refused('- active\n- inactive\n')).to.deep.equal({ kind: 'noKey' }); }); it('treats a document that is not an object as no specification', () => { @@ -36,7 +52,7 @@ describe('OpenApiDocument', () => { it('reads a document written with a byte-order mark', () => { expect(readJson({ openapi: '3.0.0' }, BYTE_ORDER_MARK).format()).to.deep.equal({ supported: true }); - expect(read(`${BYTE_ORDER_MARK}openapi: 3.0.0\n`, YAML_FILE).format()).to.deep.equal({ supported: true }); + expect(read(`${BYTE_ORDER_MARK}openapi: 3.0.0\n`).format()).to.deep.equal({ supported: true }); }); }); @@ -157,7 +173,7 @@ describe('OpenApiDocument', () => { ...aliases ].join('\n'); - expect(read(yaml, YAML_FILE).endpoints().map(String)).to.deep.equal(['GET /merged']); + expect(read(yaml).endpoints().map(String)).to.deep.equal(['GET /merged']); }); it('lists the inline operations of every path, leaving a path item in another file to its reference', () => { @@ -217,6 +233,114 @@ describe('OpenApiDocument', () => { }); }); + describe('schemaIds', () => { + it('names each $id the document declares, however deep', () => { + const document = readJson({ Pet: { $id: 'pet.json', properties: { tag: { $id: 'tag.json' } } } }); + + expect([...document.schemaIds()]).to.deep.equal(['pet.json', 'tag.json']); + }); + }); + + describe('references', () => { + const specDirectory = new DirectoryPath('/project/src/spec'); + const referenced = (document: unknown, rootIds = new Set()) => + readJson(document).references(specDirectory, rootIds).files.map(String); + const inSpec = (file: string) => path.resolve('/project/src/spec', file); + + it('finds a $ref on any object, in data, extensions and lists too, against the folder of the document', () => { + const files = referenced({ + openapi: '3.0.0', + paths: { '/pets': { $ref: './paths/pets.yaml' } }, + components: { + schemas: { Pet: { allOf: [{ $ref: 'schemas/Base.yaml' }] } }, + examples: { Pet: { value: { $ref: 'examples/pet.json' } } } + }, + 'x-shared': { $ref: '../shared/owners.yaml#/Owner' } + }); + + expect(files).to.deep.equal([ + inSpec('paths/pets.yaml'), + inSpec('schemas/Base.yaml'), + inSpec('examples/pet.json'), + path.resolve('/project/src/shared/owners.yaml') + ]); + }); + + it('reads no further into an object with a $ref, as the bundler does not', () => { + const files = referenced({ Pet: { $ref: './pet.yaml', properties: { owner: { $ref: './owner.yaml' } } } }); + + expect(files).to.deep.equal([inSpec('pet.yaml')]); + }); + + it('leaves out references into the document, URLs, and what the bundler has embedded', () => { + const files = referenced({ + local: { $ref: '#/components/schemas/Pet' }, + remote: { $ref: 'https://example.com/pet.yaml' }, + 'x-ext': { abc123: { $ref: './embedded.yaml' } }, + 'x-ext-urls': { abc123: { $ref: './mapped.yaml' } } + }); + + expect(files).to.be.empty; + }); + + // The bundler fetches only these, and reads `C:/shared/pet.yaml` as a path, as it does any other scheme. + it('takes only http and https for URLs, and any other scheme for the name of a file', () => { + const files = referenced({ + a: { $ref: 'file:///shared/pet.yaml' }, + b: { $ref: 'urn:pet' }, + c: { $ref: 'ab:c.yaml' }, + d: { $ref: 'http://example.com/pet.yaml' } + }); + + expect(files).to.deep.equal([inSpec('file:///shared/pet.yaml'), inSpec('urn:pet'), inSpec('ab:c.yaml')]); + }); + + it('sets apart, as written, each $ref that names no file and no part of a document by a pointer', () => { + const { files, unnamed } = readJson({ + empty: { $ref: '' }, + whole: { $ref: '#' }, + anchor: { $ref: '#pet' }, + pointer: { $ref: '#/components/schemas/Pet' }, + root: { $ref: '#/' } + }).references(specDirectory, new Set()); + + expect(files).to.be.empty; + expect(unnamed).to.deep.equal(['', '#', '#pet']); + }); + + it('reads no further below an $id, against which the bundler resolves what is under it', () => { + expect(referenced({ Pet: { $id: 'pet.json', properties: { tag: { $ref: './tag.yaml' } } } })).to.be.empty; + }); + + // The bundler takes the $ids of the document it starts from, and only those, as names of schemas. + it('leaves out a reference naming an $id of the root document, as a schema rather than a file', () => { + const files = referenced( + { owner: { $ref: 'pet.json#/properties/tag' }, tag: { $ref: './tag.yaml' } }, + new Set(['pet.json']) + ); + + expect(files).to.deep.equal([inSpec('tag.yaml')]); + }); + + it('follows a reference in a document that is a list', () => { + expect(referenced([{ name: 'limit' }, { $ref: './parameters/offset.yaml' }])).to.deep.equal([ + inSpec('parameters/offset.yaml') + ]); + }); + + it('reads a backslash as a slash, and leaves a percent escape as written', () => { + const files = referenced({ a: { $ref: '.\\schemas\\Pet.yaml' }, b: { $ref: './my%20pet.yaml' } }); + + expect(files).to.deep.equal([inSpec('schemas/Pet.yaml'), inSpec('my%20pet.yaml')]); + }); + + it('survives an alias that holds itself', () => { + const document = read('openapi: 3.0.0\nloop: &loop\n again: *loop\n next: { $ref: ./next.yaml }\n'); + + expect(document.references(specDirectory, new Set()).files.map(String)).to.deep.equal([inSpec('next.yaml')]); + }); + }); + describe('endpointsAt', () => { it('names the operations of the path item a pointer locates after the path it is mounted at', () => { const file = readJson({ get: {}, 'x-library': { owners: { post: {} } } }); diff --git a/test/types/portal/preview-config.test.ts b/test/types/portal/preview-config.test.ts index a0b2eb771..e87a87ff9 100644 --- a/test/types/portal/preview-config.test.ts +++ b/test/types/portal/preview-config.test.ts @@ -41,5 +41,13 @@ describe('PreviewConfig', () => { expect(preview.show(named('Pets'), false)).to.equal(true); expect(preview.show(named('Pets'), false)).to.equal(false); }); + + it('takes a refusal as fixed by a save that also wrote something, and not again after it', () => { + const preview = new PreviewConfig(named('Pets'), false); + preview.refuse(); + + expect(preview.show(named('Cats'), true)).to.equal(true); + expect(preview.show(named('Cats'), false)).to.equal(false); + }); }); }); diff --git a/test/types/portal/preview-refusal.test.ts b/test/types/portal/preview-refusal.test.ts new file mode 100644 index 000000000..7ad277b10 --- /dev/null +++ b/test/types/portal/preview-refusal.test.ts @@ -0,0 +1,15 @@ +import { expect } from 'chai'; +import { PreviewRefusal } from '../../../src/types/portal/preview-refusal'; + +describe('PreviewRefusal', () => { + it('is fixed by the first save accepted after a refusal, and by no other', () => { + const refusal = new PreviewRefusal(); + expect(refusal.accept()).to.be.false; + + refusal.refuse(); + refusal.refuse(); + + expect(refusal.accept()).to.be.true; + expect(refusal.accept()).to.be.false; + }); +}); diff --git a/test/types/portal/preview-specs.test.ts b/test/types/portal/preview-specs.test.ts new file mode 100644 index 000000000..63cf8a02b --- /dev/null +++ b/test/types/portal/preview-specs.test.ts @@ -0,0 +1,62 @@ +import { expect } from 'chai'; +import { DirectoryPath } from '../../../src/types/file/directoryPath'; +import { FileName } from '../../../src/types/file/fileName'; +import { FilePath } from '../../../src/types/file/filePath'; +import { PortalSpec } from '../../../src/types/portal/portal-source'; +import { PreviewSpecs } from '../../../src/types/portal/preview-specs'; + +describe('PreviewSpecs', () => { + const spec = new DirectoryPath('src', 'spec'); + const document = (name: string): PortalSpec => ({ + slug: name, + file: new FilePath(spec, new FileName(name)), + endpoints: [] + }); + const named = (files: FilePath[]) => files.map((file) => file.name().toString()); + + /** The change each save warns of, by the names added and removed, for a preview started with `startup`. */ + const warnings = (startup: string[], saves: string[][]) => { + const preview = new PreviewSpecs(startup.map(document)); + return saves.map((names) => { + const { change } = preview.show(names.map(document)); + return change === null ? null : { added: named(change.added), removed: named(change.removed) }; + }); + }; + + it('warns of nothing while the documents are the ones it started with', () => { + expect(warnings(['pets.json'], [['pets.json'], ['pets.json']])).to.deep.equal([null, null]); + }); + + it('warns of a document added or removed since it started, on the save that does it and not after', () => { + expect( + warnings(['pets.json'], [['orders.json', 'pets.json'], ['orders.json', 'pets.json'], ['orders.json']]) + ).to.deep.equal([ + { added: ['orders.json'], removed: [] }, + null, + { added: ['orders.json'], removed: ['pets.json'] } + ]); + }); + + // The restart it was told it needs is no longer needed. + it('says once that nothing differs on a return to the documents it started with, and warns of the change made anew', () => { + expect( + warnings( + ['pets.json'], + [['orders.json', 'pets.json'], ['pets.json'], ['pets.json'], ['orders.json', 'pets.json']] + ) + ).to.deep.equal([ + { added: ['orders.json'], removed: [] }, + { added: [], removed: [] }, + null, + { added: ['orders.json'], removed: [] } + ]); + }); + + it('says the specifications are fixed once, on the first save it accepts after refusing one', () => { + const preview = new PreviewSpecs([document('pets.json')]); + preview.refuse(); + + expect(preview.show([document('pets.json')]).fixed).to.be.true; + expect(preview.show([document('pets.json')]).fixed).to.be.false; + }); +}); diff --git a/test/types/portal/spec-bundle.test.ts b/test/types/portal/spec-bundle.test.ts new file mode 100644 index 000000000..fdb724994 --- /dev/null +++ b/test/types/portal/spec-bundle.test.ts @@ -0,0 +1,77 @@ +import { expect } from 'chai'; +import path from 'path'; +import { DirectoryPath } from '../../../src/types/file/directoryPath'; +import { FilePath } from '../../../src/types/file/filePath'; +import { OpenApiDocument } from '../../../src/types/portal/openapi-document'; +import { SpecBundle } from '../../../src/types/portal/spec-bundle'; + +describe('SpecBundle', () => { + const spec = new DirectoryPath(path.resolve('/project/src/spec')); + const at = (name: string) => FilePath.resolve(spec, name); + const parsed = (value: unknown) => OpenApiDocument.parse(JSON.stringify(value))._unsafeUnwrap(); + + /** Each file still to read, and the file that names it. */ + const unread = (bundle: SpecBundle) => + bundle + .unread() + .map(({ file, referencedBy }) => `${file.relativeTo(spec)}, named in ${referencedBy.relativeTo(spec)}`); + + it('lists each file named and not yet read once, with the first file to name it, and ends at a cycle', () => { + const bundle = new SpecBundle( + at('api.json'), + parsed({ Pet: { $ref: './Pet.json' }, Pets: { items: { $ref: './Pet.json#/Pet' } } }) + ); + expect(unread(bundle)).to.deep.equal(['Pet.json, named in api.json']); + + bundle.add(at('Pet.json'), parsed({ owner: { $ref: './Owner.json' }, root: { $ref: './api.json' } })); + expect(unread(bundle)).to.deep.equal(['Owner.json, named in Pet.json']); + + bundle.add(at('Owner.json'), parsed({ pet: { $ref: './Pet.json' } })); + expect(unread(bundle)).to.be.empty; + }); + + // The bundler turns `#` and `#pet` in a file it embeds into pointers to where it put the file. + it('keeps each $ref the bundler fails on: an empty one anywhere, and a bare # or an anchor in the root only', () => { + const bundle = new SpecBundle( + at('api.json'), + parsed({ whole: { $ref: '#' }, anchor: { $ref: '#pet' }, Pet: { $ref: './Pet.json' } }) + ); + bundle.add(at('Pet.json'), parsed({ whole: { $ref: '#' }, anchor: { $ref: '#pet' }, empty: { $ref: '' } })); + + expect(bundle.unresolvable().map(({ file, ref }) => [file.relativeTo(spec), ref])).to.deep.equal([ + ['api.json', '#'], + ['api.json', '#pet'], + ['Pet.json', ''] + ]); + }); + + it('names what a file refers to from its own folder', () => { + const bundle = new SpecBundle(at('api.json'), parsed({ Pet: { $ref: './schemas/Pet.json' } })); + + bundle.add(at('schemas/Pet.json'), parsed({ owner: { $ref: './Owner.json' } })); + + expect(unread(bundle)).to.deep.equal(['schemas/Owner.json, named in schemas/Pet.json']); + }); + + it('lists the operations of a path item kept in another file after its own', () => { + const bundle = new SpecBundle( + at('api.json'), + parsed({ openapi: '3.0.0', paths: { '/pets': { $ref: './paths.json#/pets' }, '/health': { get: {} } } }) + ); + + bundle.add(at('paths.json'), parsed({ pets: { get: {}, post: {} } })); + + expect(bundle.endpoints().map(String)).to.deep.equal(['GET /health', 'GET /pets', 'POST /pets']); + }); + + it('lists once an operation declared both beside the $ref of a path item and in the file it refers to', () => { + const bundle = new SpecBundle( + at('api.json'), + parsed({ openapi: '3.0.0', paths: { '/pets': { $ref: './paths.json#/pets', get: {} } } }) + ); + + bundle.add(at('paths.json'), parsed({ pets: { get: {}, post: {} } })); + + expect(bundle.endpoints().map(String)).to.deep.equal(['GET /pets', 'POST /pets']); + }); +});