diff --git a/.ai/plans/portal-subpath-hosting.md b/.ai/plans/portal-subpath-hosting.md new file mode 100644 index 000000000..ffb672f74 --- /dev/null +++ b/.ai/plans/portal-subpath-hosting.md @@ -0,0 +1,1071 @@ +# Plan: hosting the portal under a sub-path + +Status: designed 2026-09-29 on `saeedjamshaid/portal-subpath-hosting`, cut from `origin/dev` at +`74ad5d2e`. Tracks apimatic-io#2275, whose design this follows except where section 14 says +otherwise. Reviewed four times on 2026-09-29; section 15 records what each round changed. + +Implemented in steps 1–4 on 2026-09-29 and 30, and opened as apimatic/apimatic-cli#411, which +supersedes #407. Step 5 (section 7) is the follow-up outside this repository. + +## 1. Goal and scope + +`portal.site.url` may carry a path, such as `https://acme.github.io/docs` for a GitHub Pages +project site or a docs prefix behind a reverse proxy. When it does, the portal works when it is +served at that path: its pages, assets, client navigation, search, downloads, SEO files and +`portal serve`. + +- **In scope:** the path comes from `site.url`, and only from there. +- **Out of scope** (decided 2026-09-29; section 12 says why): + - a path given without `site.url`, as a setting or a flag; + - one build that works at whatever path it is deployed to. +- **Invariant:** a portal whose `site.url` has no path, or that has no `site.url`, emits the same + pages, files and addresses as today. The content hashes in chunk names are the only difference, + apart from the image fixes of 5.10 and 5.12, which apply at the root too: reference-style images + are bundled and checked like inline ones, an image in a description no longer fails its page, + and search results show no image from a description. + +**Terms:** +- A **portal-relative path** is a page's path from the portal's own root (`/guides/intro`). +- The **base path** is where the portal is mounted (`/docs`, or `''` at a host's root). +- The **site address** is the origin plus the base path (`https://acme.github.io/docs`). +- The **served path** is the base path plus the portal-relative path. + +The comments that say "site-relative" today (`portal-config.ts:29,31`, `portal-types.ts`) are +changed to "portal-relative". `StaticAsset.siteUrl()`, which returns a portal-relative path, is +renamed `portalPath()` (4.3). + +## 2. Decisions (2026-09-29) + +1. **Where the path comes from.** It is the path of `site.url`. Without `site.url` the portal is + at the root of its host, as today. +2. **Vite's `base` is the one switch.** TanStack Start derives the rest from it: + - the router basepath (`deriveRouterBasepath`, start-plugin-core `planning.js:14`); + - every page's asset URLs (the Start manifest is built from `publicBase`); + - the prerender, which fetches `withBase(page)` and writes `withoutBase(file)` (`prerender.js:78,92`). + + Fumadocs' links are TanStack Router links (`fumadocs-core/dist/framework/tanstack.js`), which + already carry the basepath. So `docsRoute` stays `/`; a Fumadocs `baseUrl` would double every + link (`/docs/docs/guides`). TanStack's maintainer puts the split the same way on #4888: Vite + `base` owns assets, and the router basepath owns routes. +3. **One source for the path: the CLI's `SiteAddress`** (revised 2026-10-08, section 15). + - `siteUrl` in `portal.identity.json` becomes the site address. + - `portal.config.json` carries `base`, the address's `path()`, and `vite.config.ts` takes it as + it is. The CLI writes both files from one `SiteAddress` when it prepares the build. + - Not in `portal.identity.json`: the browser imports that file whole, and it already has the + base as `import.meta.env.BASE_URL` (decision 4). + - Until 2026-10-08 the template worked `base` out of `siteUrl` a second time, in `viteBase`, and + a contract test held that to `SiteAddress.path()`. +4. **Portal-relative strings are prefixed in the template.** The strings the router never sees + go through one helper, `withBasePath()`, which reads `import.meta.env.BASE_URL`. + - The template is the layer that already adds the base to the router's URLs. Prefixing in the + CLI would give the same rule a second home. + - `BASE_URL` is the value Vite actually applied. Under `portal serve`, an edit that moves the + path rewrites `siteUrl` while Vite keeps its startup base (decision 9), so reading `siteUrl` + there would disagree with the assets. + - The code that runs in Node passes the base explicitly: the prerender list, the dev + downloads middleware and the cache plugin. So the prefix rule has one home. +5. **`siteUrl` carries the whole site address.** `seo.ts` and `sitemap.server.ts` concatenate + `siteUrl + pageUrl`, so these follow with no code change: canonical, `og:url`, the sitemap, and + the plugin's Markdown install command. +6. **A base path that collides with page paths works; nothing is refused.** A collision is a + page whose portal-relative path starts with the base, such as `/api` against the API reference, + or `/docs` against a `docs/` folder. Two things break on one today, and each gets a fix in the + template: + - **The prerender.** ufo's `withBase` leaves a path that already starts with the base unchanged + (ufo 1.6.4), so `/docs/intro` under `/docs` is fetched as `/intro` and 404s. Fixed by giving + TanStack the served paths (5.7): `withBase` then leaves every one alone, and `withoutBase` + strips the base exactly once for the filename. + - **Search results.** They navigate through `router.navigate({ href })`, whose input rewrite + strips a leading base, case-insensitively (`router-core router.js:261-265`, `rewrite.js`). + Fixed by handing the dialog served URLs (5.2), so the rewrite strips exactly once. The dialog + is the only place in Fumadocs that navigates by `href`. + + Router links (`to`) and direct loads already strip once, correctly. All of this was measured + under base `/api`, in the worst case (section 3). +7. **No `robots.txt` under a path.** Crawlers read it only from the root of a host. The Next + Steps note says where the sitemap is instead. +8. **The static server-function cache URL is rewritten in the client build** by a Vite plugin in + the template, until TanStack/router#6152 ships. The rejected alternatives: + - a pnpm patch, which never reaches users who install the CLI with npm; + - a copy of the middleware, which needs `@tanstack/start-client-core` and `seroval` as direct + dependencies pinned to Start's versions; + - a wrapper round the global `fetch`, which changes a browser global for every caller. + + TanStack offers no option for this URL (`staticFunctionMiddleware.ts:50` on `main`), and the + latest release is 1.167.38. +9. **`portal serve` runs under the base**, as the build does. The 2026-09-29 dev probe found + routing, the `/docs/_serverFn/` page data, HMR and live edits all working there, and it surfaced + the same missing prefixes a build shows. + - It reports and waits on the address with its trailing slash. + - An edit that moves the path is applied, so canonical links follow, but the preview keeps + serving the old path until it restarts. The restart list in the Live preview note names it. + There is no notice of its own: the path is set once, and a notice would mean reworking the + static-directory one into a keyed prompt, which is not worth it for so rare an edit. +10. **Links in the Markdown copies.** + - **In scope:** the links we write into the SDK pages' Markdown copies get the base (5.2). + - **Deferred to a follow-up** (decided 2026-09-29): the links authors write keep their + spelling in the copies. The reasons: + - It is the one part whose mechanism is still open (section 9). + - It affects only the copies handed to AI assistants; the HTML already follows the base. + - No portal is hosted under a path yet. + - It would couple us to another Fumadocs internal. +11. **The quickstart note stays as #403 left it.** The change is carried by: + - the schema description; + - the validation messages; + - the Next Steps note of `portal generate`; + - the README. + +## 3. What was verified (2026-09-29) + +This covers the installed versions: Vite 8.2.2, @tanstack/react-start 1.168.50, start-plugin-core +1.171.40, router-core 1.171.28, start-static-server-functions 1.167.33, fumadocs-core/ui 16.15.15, +fumadocs-mdx 15.4.0. **Measured** means it was built or run; **read** means it was read in the +installed source. + +**Measured: the `default` fixture built with Vite `base: '/docs/'`, served at `/docs`, and driven +in headless Chrome.** In the fixes column, the `/__tsr/` fix was a hand-written fetch wrapper; the +planned plugin was verified afterwards (see the next list). + +| | `base` only | `base` + search, `/__tsr/` and logo fixes | +|---|---|---| +| First page load | works | works | +| Client navigation | the 404 page: `/__tsr/staticServerFnCache/*.json` 404s | works | +| Search | no results: `/api/search.json` 404s | works | +| Logo, favicon | 404 | work | +| Failed requests | 5 | 0 | + +**Measured, with the planned plugin (5.6) in place of the fetch wrapper:** +- Picking a search result navigates to `/docs/api/…/Calculate`, and its cache fetch answers 200. +- An unknown deep link served through `404.html` shows the not-found page, with `/docs/` links. +- Cache files are written to `dist/client/__tsr/`. + +**Measured: a colliding base.** +- `base: '/api/'` as things stand fails the build (`Failed to fetch /api/search.json: Not Found`). +- With served paths handed to the prerender and served URLs handed to the search dialog, it + builds, and the output layout is unchanged: `api/search.json` and + `api//…/Calculate/index.html` are at the output's root. +- In the browser, all of these work: + - a direct load of `/api/api/…/Calculate`; + - a sidebar click to it and back; + - clicking a search result; + - a deep link through `404.html`. + +**Measured with step 3 in place (2026-09-30):** the `subpath` fixture built and served at +`/api`, with `404.html` for missing paths, and driven in headless Chrome. All of these work: +- first load, with the logo, the Markdown image and the favicon; +- a click to a content page and to a colliding `/api/api/…` reference page, and back. Their + cached data comes from `/api/__tsr/…`, with status 200; +- the popover's "View as Markdown" (`http://…/api/authentication.md`, 200), the prompt's page + address, and the copy button's fetch; +- search, and a click on a result landing on the colliding page; +- the SDK download (200), the plugin page's install command with `/api`, a direct load of a + reference page, and a missing page shown as the not-found page with `/api/` links. + +The only console output was also there on the root-hosted `default` build, driven the same way: +- headless Chrome refusing the clipboard to an unfocused document; +- the not-found page's own failed data lookup. + +**Measured with step 4 in place (2026-09-30):** `portal serve` on the `subpath` fixture, run as +the action runs with only the account check and the artifacts stubbed, and driven in headless +Chrome. It printed `http://127.0.0.1:/api/`, and the restart list named the path. All of +these work, with no failed request and no console output: +- the printed link, with the logo, the Markdown image and the favicon; +- a click to a content page and to a colliding `/api/api/…` reference page, and back, their data + from `/api/_serverFn/…`; +- search, and a click on a result landing on the colliding page; +- the SDK download and the plugin's (200, both under `/api/__downloads/`), and the install + command's address; +- a missing page, shown as the not-found page. + +Moving the path to `/v2` in the running preview applied the edit (canonical links then carry +`/v2`), while `/api/` went on being served and `/v2/` answered 404, as the restart list says. +Stopping it the way CTRL+C does left no project directory behind. + +**Measured, other builds and runs:** +- `base: './'` writes script URLs as `/./assets/…` (the host's root), CSS links relative to the + page, and a router basepath of `.`. +- Vite dev with `base: '/docs/'`: + - `/docs/` answers 200, `/docs` answers 404 and `/` answers 302 to `/docs/`; + - routing, `/docs/_serverFn/…` page data, the HMR websocket (`ws://…/docs/`) and a live content + edit all work; + - `/docs/__downloads/plugin.zip` comes back as the SPA's HTML, while a bare `/__downloads/…` + answers with the zip, because our middleware runs ahead of Vite's base check. +- In the `/docs/` build: + - pages are written to `dist/client/guides/intro/index.html`, and the host serves that + directory at `/docs`; + - router links, `