Skip to content

feat(portal): build the documentation portal locally with Fumadocs - #343

Merged
saeedjamshaid merged 97 commits into
devfrom
saeedjamshaid/fumadocs-portal
Sep 21, 2026
Merged

saeedjamshaid merged 97 commits into
devfrom
saeedjamshaid/fumadocs-portal

Conversation

@saeedjamshaid

Copy link
Copy Markdown
Collaborator

Summary

Replaces the hosted portal generation with a portal built on the user's machine. portal generate now copies a Fumadocs / TanStack Start template into a temp project, points it at the user's src/, runs Vite, and writes a fully static site. portal serve runs the same template on Vite's dev server with live reload. Nothing is uploaded to build a portal any more; the only network call is the entitlement check.

Design and decisions: .ai/plans/fumadocs-portal.md. Section 12 lists what remains after this PR.

What changes for users

  • New input layout, additive: src/portal.json (title, description, logo, siteUrl), src/spec/ (shared with SDK generation), src/content/ (Markdown with front matter, ordered by meta.json), src/static/. APIMATIC-BUILD.json stays for the SDK and plugin commands only. Both commands print a migration hint when they find generatePortal in the build file and no portal.json.
  • Removed: portal toc new, portal recipe new, portal copilot. Hidden stubs remain for one major, explain what replaced them, and exit 1.
  • portal serve drops --destination and --no-reload; reload is always on.
  • Node 22.12 or newer is required to build a portal. The engine field says so and the commands check before building.
  • Quickstart scaffolds the new layout and downloads its default spec from the sample repository's permanent v2 branch.
  • Request samples: curl only. Other languages will come from x-codeSamples in the spec.

How it is built

  • portal-template/ ships in the package. Per-dependency junctions or symlinks link the CLI's own installed packages into the temp project, so users install nothing extra.
  • SPA mode with an explicit prerender page list: every URL is complete HTML before JavaScript runs, then hydrates. Per-page title, description and canonical link; sitemap and robots when siteUrl is set; Orama search index; llms.txt and a .md twin of every page.
  • Modules that read the spec off disk live in *.server.ts files; TanStack import protection fails the build if client code imports one. This closes a bug where the loader shipped to the browser and left every page inert.
  • Each operation page carries only its own path item and the components it references; the sidebar tree is loaded once from the root route.

Verification

  • 328 tests locally on Windows / Node 23.4, including the gated end-to-end build (APIMATIC_E2E=1) and the new suites for the config, source context, authorization gate, project service, template packaging, and the document trim.
  • Generated pages and portal serve verified in headless Chrome: no console errors, code highlighting and interactive controls appear after hydration.
  • Whole-spec builds measured (Petstore 12.6 MB; Stripe 594 ops 2.2 GB in 375 s; GitHub 1,511 pages 2.4 GB in 229 s). For large specs the sidebar tree, repeated in every page, dominates output; noted as the next follow-up in the plan.
  • Not yet run: the new Tests workflow's ubuntu / windows / macos x Node 22.12 / 24 matrix. This PR is what triggers it for the first time.

Squash commit footer

The squash commit needs this footer so semantic-release cuts the major:

BREAKING CHANGE: building a portal requires Node >= 22.12 and the new src/portal.json layout.
portal toc new, portal recipe new and portal copilot are removed (hidden stubs exit 1);
portal serve drops --destination and --no-reload. Run `apimatic autocomplete --refresh-cache`
to drop the removed commands from shell completion.

Known and accepted for this cut

  • $ref values in a spec are read by Fumadocs' bundler during the build, including file paths and URLs (plan, section 9).
  • Generated sites load Geist from Google Fonts and the page actions link to ChatGPT, Claude and Cursor.
  • The runtime set is pinned exactly; no Renovate or Dependabot config exists yet to move it.

🤖 Generated with Claude Code

https://claude.ai/code/session_01MfFVFknGtZmWnwUf1ntQrE

saeedjamshaid and others added 21 commits September 16, 2026 17:20
Design for the next major release: replace server-side portal generation
with a locally built, fully static Fumadocs portal on TanStack Start.
Covers the new src layout (portal.json, spec/, content/, static/), build
mechanics verified in a spike, the portal authorization gate, command
changes (generate replaced, serve rewired, toc/recipe/copilot removed),
tests, risks and PR sequencing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MfFVFknGtZmWnwUf1ntQrE
The config used module.exports inside a "type": "module" package, so
commitlint could not load it, and @commitlint/config-conventional 15 is
incompatible with @commitlint/cli 20 (Q.all(...).spread crash). Rename
the config to .cjs and align config-conventional with the CLI version.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MfFVFknGtZmWnwUf1ntQrE
Fold in the decisions taken on three independent reviews: single PR with
a breaking-change footer, keep --zip, drop the env-var auth tier, per-
dependency node_modules links instead of one junction, explicit prerender
pages list and a dedicated SPA shell route so the root index.html exists,
allow-listed child environment, Vite dev server for portal serve, hidden
stubs for removed commands, 1.x maintenance branch and a CI test job,
re-verification on release-age-compliant versions, and the deferred $ref
file-read/SSRF exposure recorded as a known risk.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MfFVFknGtZmWnwUf1ntQrE
Content dir must be a string literal for the Fumadocs defineDocs macro,
so the CLI generates that file; specs and static dir come from the JSON
config (verified end to end, static files reach the output via publicDir).
The dev server reflects spec and content edits with the static OpenAPI
source, so the dynamic source is dropped (the multi-source loader rejects
it). Serve flags corrected to the real current set, express/livereload/
chokidar confirmed removable, dev-server startup figures updated, and
temp-dir cleanup must use Node fs.rm because shell recursive deletion
follows junctions on Windows.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MfFVFknGtZmWnwUf1ntQrE
Ships the Vite project the CLI builds a documentation portal with, so the
portal is produced on the user's machine instead of by the platform API.

The template reads the user's src/ by absolute path and copies nothing: the
CLI writes portal.config.json beside it with the spec, content and static
locations. Content pages need one exception, because the Fumadocs macro that
loads them only accepts a string literal, so the CLI substitutes that one
path when it prepares the project.

Dependencies are pinned to exact versions that satisfy the registry's
seven-day minimum release age. Express, livereload, connect-livereload and
chokidar are dropped; they were only used by the old serve action, which is
rewritten separately, so the tree does not compile until that lands.

Excludes the template from the package's own TypeScript, ESLint and Prettier
runs, and anchors the lib/ ignore rule to the repository root: unanchored, it
also matched portal-template/src/lib, which every route imports from.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MfFVFknGtZmWnwUf1ntQrE
Replaces the platform generation call in `portal generate` with a local Vite
build, and rebuilds `portal serve` on the Vite dev server so a preview picks
up edits to src/ without regenerating anything.

Both commands share one pipeline: check the Node runtime, check the account
may generate on-premises, validate the source directory, then prepare a
throwaway project in a temp directory and either build or serve it. The
project links one dependency at a time rather than the whole node_modules,
because a global, npx or dlx install has no nested copy to link to. The child
process gets an allow-list environment, so the auth key cannot reach the
build and no stray VITE_ variable can be inlined into the output.

Portals are configured by src/portal.json. Its optional siteUrl unlocks the
parts of search engine optimisation that need an absolute address: a
sitemap, a robots.txt pointing at it, and a canonical link per page. Without
it the portal still builds and those are left out, since a wrong canonical is
worse than none. This matters more than usual because a prerendered page only
carries links for its own sidebar branch, so a crawler cannot reach every
page by following links.

A project still holding APIMATIC-BUILD.json is offered a portal.json built
from its old page title and logo, and told which of its settings have no
equivalent yet.

Also refuses a destination that contains the source directory. The
destination is emptied before the site is written, and the previous guard
only compared the two paths for equality, so `-i . -d .` deleted the user's
src/ and reported success.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MfFVFknGtZmWnwUf1ntQrE
`portal toc new`, `portal recipe new` and `portal copilot` all wrote into
APIMATIC-BUILD.json for a portal the platform generated. A portal is now
built on the user's machine from `src/portal.json`, where page order comes
from `meta.json` and operations group by their tags, so none of the three
has anything left to configure.

Each keeps a hidden stub that names what to do instead and exits non-zero,
so an existing script gets an explanation rather than "command not found".
Their actions, prompts, algorithms, types and telemetry events go with
them, as do the portal, table-of-contents and SDL calls in PortalService
and the build-file prune path that only the old portal quickstart used.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MfFVFknGtZmWnwUf1ntQrE
The portal flow no longer downloads the sample repository, asks which SDK
languages to include, or prunes the build file against the subscription;
none of that reaches a locally built portal. It writes the smallest tree
`portal generate` accepts instead — `portal.json`, the specification under
`spec/`, and a `content/` directory with a starter page and the `meta.json`
that orders it.

Title and description come from the specification's own `info` block, so
the step asks nothing extra, and the entitlement check moved ahead of the
questions: the flow ends in `portal serve`, which refuses without it, and
finding that out after four prompts would be rude.

That leaves three steps rather than four, so the wizard fronting it no
longer offers "API Portal + Context Plugins + SDKs" or promises four.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MfFVFknGtZmWnwUf1ntQrE
Spec sections mount at /api/<slug>, where the slug comes from the file
name, and the portal's own search index is a route at /api/search. A
project holding `spec/search.json` therefore had the build try to write a
file where that route's directory already stood, and it failed with an
EISDIR naming a path inside a temporary directory — nothing that points
back at the specification, let alone at renaming it.

Claiming the name before any spec can take it sends such a file to
`search-2` instead. The regression test arrives with the rest of the
suite in the next commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MfFVFknGtZmWnwUf1ntQrE
Unit cover for the pieces the portal commands lean on: the `portal.json`
value object and every field error it reports, source discovery with the
migration hint a pre-2.0 build file produces, the entitlement gate and its
three failure modes, and dependency linking with the child environment the
build runs under. A packaging test keeps the template from importing
anything the CLI does not declare, since such an import resolves here and
fails only on a user's machine.

The end-to-end test drives the real pipeline — resolve, prepare, build,
save — against a fixture in the new layout. A Vite build needs every
runtime dependency and takes tens of seconds, so it runs only when
APIMATIC_E2E is set; CI switches it on. The old `build-inputs` fixture
becomes `portal-inputs`, with `portal.json` and a content directory in
place of the `toc.yml` files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MfFVFknGtZmWnwUf1ntQrE
The portal build runs Vite inside a temporary project whose node_modules is
one link per dependency, which is the part most likely to behave
differently between Windows, macOS and Linux. The workflow runs the suite
on all three against Node 22.12 and 24, with APIMATIC_E2E set so the real
build runs, and without fail-fast so one platform failing does not hide the
result on the others.

release.config.cjs gains a `1.x` branch: once 2.0 prereleases exist on the
other branches, a patch to the shipped major would otherwise have nowhere
to go.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MfFVFknGtZmWnwUf1ntQrE
The skills used the table-of-contents and recipe code as their worked
examples for nested topics, minimal actions, failure events and the
composite context, and every one of those files is gone. Each reference
moves to a surviving example, the context table now cites BuildContext for
the composite pattern, and the architecture note records that no
Application-layer algorithm remains.

The portal plan described a `content-dir` module the implementation
replaced with a placeholder substituted into `source.ts`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MfFVFknGtZmWnwUf1ntQrE
The page list computed in vite.config.ts is complete, yet crawling was still
on at the page level, so the prerenderer also followed every root-relative
link it found in the rendered HTML. A specification whose descriptions link to
its author's own site, as Stripe's does with "/docs/connect", made it fetch a
path the portal does not have, and the 404 failed the whole build.

The flag set earlier under `spa.prerender` only governs the shell page; the
per-page setting is the top-level `prerender.crawlLinks`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MfFVFknGtZmWnwUf1ntQrE
`src/lib/source.ts` read every OpenAPI document off disk at module load and
was imported by the route files, so `fumadocs-openapi/server` shipped to the
browser. There it threw "[OpenAPI] Failed to resolve input" before React could
hydrate: `portal serve` showed a blank page, and a generated portal looked
right but had no working search, navigation or API panels.

Everything that touches the filesystem now lives in `*.server.ts` modules
that only server functions and server route handlers import, and `source.ts`
keeps just the content collection the browser needs. TanStack's import
protection fails the build if client code imports one of those modules or the
library entry points again, rather than mocking the import.

Verified in headless Chrome: the same page now hydrates with no console
errors, and the end-to-end test asserts the loader's error string is absent
from every client asset.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MfFVFknGtZmWnwUf1ntQrE
Every operation page carried the whole bundled specification twice, once
inlined in its router state and once in its server-function cache file, plus
the complete sidebar tree in both places. Output therefore grew with pages
times document size: 150 Stripe operations produced 812 MB.

The sidebar tree is now loaded once by the root route, so it lands in a single
cache file, and `slimOpenAPIPageProps` cuts each page's document down to its
own path items and webhooks, the components they reach transitively, and the
document-level fields the client reads. Rendered pages are byte-identical
before and after, and no trimmed document is left with a dangling reference.

The same Stripe build now produces 469 MB. Stripe's schema graph is nearly
fully connected, so it keeps most of its components on every page; specs shaped
like GitHub's or Petstore's drop to a few kilobytes per page.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MfFVFknGtZmWnwUf1ntQrE
…the plan

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MfFVFknGtZmWnwUf1ntQrE
`test-source`, `test-destination` and `test-portal` are already set aside by
.gitignore for local runs, but a generated portal left in `test-portal` was
being linted as part of `pnpm test` and failing it on minified assets.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MfFVFknGtZmWnwUf1ntQrE
…rver.ts

The rename in 2a0d8e6 was meant to change this import from `./source` to
`./source.server` along with it. The first commit attempt failed inside the
pre-commit hook, and lint-staged's "hiding unstaged changes to partially staged
files" step discarded the edit without restoring it, so the rename landed with
the old import and every portal build failed with:

    [MISSING_EXPORT] "source" is not exported by "src/lib/source.ts".

The end-to-end suite passes on this tree, measured after the commit's content
was staged rather than before.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MfFVFknGtZmWnwUf1ntQrE
The operation pages generated samples in seven languages by default. The
registry handed to the page component now holds the curl generator alone.
Samples in other languages will come from the `x-codeSamples` extension on
each operation, which a later change adds to the specification; the same tabs
already render those beside the generated one (verified with a spec carrying
Python and Java samples).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MfFVFknGtZmWnwUf1ntQrE
…ements

Adds section 12 (done, remaining before the PR merges, follow-ups), replaces
the spike's build measurements with the pinned-version numbers, and records
the whole-spec finding that the sidebar tree, not the document, dominates
output for large specifications.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MfFVFknGtZmWnwUf1ntQrE
…s v2 branch

`sample-docs-as-code-portal` keeps `master` for the layout version 1 sends to
the hosted generation API, and a permanent `v2` branch for the layout this CLI
builds locally. Both quickstarts now fetch their defaults from `v2`, where the
specification is named `petstore.json` so its operations publish under
`/api/petstore/`, and `APIMATIC-META.json` is unchanged. The new CLI no longer
depends on the branch that exists for the old one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MfFVFknGtZmWnwUf1ntQrE
saeedjamshaid and others added 2 commits September 17, 2026 18:29
…nother drive

fumadocs-mdx hands Vite an `import.meta.glob` whose base is
`path.relative(project, contentDir)`. Across Windows drives that is an absolute
path, which the glob turns into `./D:/...` and matches nothing, so the portal
came out without its content pages and without any error. GitHub's Windows
runners hit it on every build (workspace on D:, temp on C:), as does anyone
whose project is not on the drive that holds their temp folder.

`withBuildDirectory` keeps the system temp directory unless it sits on a
different Windows drive from the source. Then the build project goes into a
`.apimatic-build/` folder beside `src/`, carrying a `.gitignore` of `*` so it
never shows in the user's status, and is removed again afterwards. Both
commands and the end-to-end test use it. Reproduced and verified locally with
a `subst` drive: content pages present, nothing left behind.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MfFVFknGtZmWnwUf1ntQrE
pnpm 11 itself requires Node 22.13, so running it through corepack on the
matrix's 22.12 job failed before a single test ran. The standalone build
carries its own runtime, which leaves the matrix Node free to be the version
under test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MfFVFknGtZmWnwUf1ntQrE
Comment thread .github/workflows/test.yml Fixed
saeedjamshaid and others added 4 commits September 17, 2026 19:22
`buildDirectoryBase` took a `DirectoryPath`, which resolves its argument against
the host's own path rules, so on Linux and macOS the Windows paths in its tests
became paths under the runner's home directory and the assertions failed. The
rule now takes the path as a string; the callers pass `toString()`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MfFVFknGtZmWnwUf1ntQrE
The gate failed on reliability and security for new code:

- the migration hint sorted field names without a comparator, and the slug
  de-duplication loop tested one variable while incrementing another;
- the pnpm setup action was referenced by tag; it is now pinned to the v4.4.0
  commit.

Also the trivial smells in files this branch added: `node:` import prefixes and
a single return path in the build-directory helper, the compound assignment in
the document trim split in two, `replaceAll` for the plain-string replaces, and
`lang="en"` on the portal's root element. The plan records the outcome of the
config-file-mode prototype: fumadocs-mdx forms every import path with
`path.relative`, so the same cross-drive defect applies and the fix belongs
upstream.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MfFVFknGtZmWnwUf1ntQrE
Use the node: prefix for the events import, hold the migratable portal
fields in a Set, label non-string Swagger and AsyncAPI versions instead of
stringifying them, build the ANSI pattern with String.raw and
String.fromCodePoint, read the last URL segment with .at(-1), and mark the
route component props read-only.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MfFVFknGtZmWnwUf1ntQrE
…c host

The static server-function middleware fetches a prerendered response without
checking the status, so an unknown URL parses the host's 404 document as JSON.
That threw before the router could act on the notFound() the loader raises,
and the error boundary rendered in place of the not-found page on every
published portal. Convert a failed fetch to notFound() on the cache path only,
where the handler never runs and a failure can mean nothing else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread src/infrastructure/services/sdk-generation-service.ts
Comment thread src/infrastructure/portal-project-service.ts Outdated
Comment thread src/infrastructure/portal-build-service.ts Outdated
Comment thread src/types/portal-context.ts Outdated
Comment thread test/e2e/portal-build.test.ts
Comment thread src/types/portal-source-context.ts
Comment thread src/types/portal-source-context.ts Outdated
Comment thread src/types/portal-source-context.ts
Comment thread portal-template/src/components/api-page.tsx
Comment thread src/actions/portal/quickstart.ts Outdated
Comment thread src/types/portal/portal-config.ts Outdated
Comment thread src/actions/portal/quickstart.ts Outdated
Comment thread src/actions/portal/serve.ts
Comment thread src/types/portal/portal-config.ts
Comment thread portal-template/src/lib/openapi.server.ts
saeedjamshaid and others added 4 commits September 21, 2026 15:32
Site, brand, navigation, sdks, ai and advanced namespaces, each key mapped to the Fumadocs
mechanism that applies it. Sections render as tabs on top of the nav.json plan, the API
section filters deprecated and internal operations, the pre-2.0 build file migrates, and a
JSON schema ships in the package. Decided 2026-09-21 with no open questions.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CSxkcBXCrw2T6KvYLy3riU
- stage the generated site inside the destination so a failed copy keeps the
  previous portal, and keep the staged site when the swap itself fails
- report an unreadable build output and an unwritable build log instead of
  throwing past the spinner
- warn when a page under content/api shares its address with a specification
- return typed Results from the portal.json validators and drop the casts
- pass TMPDIR, LANG and LC_ALL to the build; exit 1 on an entitlement denial
- share the OpenAPI section source between the site and the prerender list
- rename PortalService to SdkGenerationService; pin the test workflow actions
- remove the cross-drive build folder after the end-to-end test

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…check

- treat a staging folder left by an unfinished save as a portal to overwrite,
  and stop reporting a finished save as failed when the empty folder cannot be
  removed
- derive content page addresses with fumadocs' own getSlugs, so a folder with
  no index page and content/api/index.md no longer trigger a false warning
- share one directory listing between getFileNames and getSubDirectoriesPaths,
  and one error-message helper between the contexts that report caught errors
- point the service skill at the renamed sdk-generation-service.ts

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Scope light tokens with :root:not(.dark) so they stay out of dark mode; give the fallback home a
tree node so it gets tabs and the CTA; write portal.identity.json and theme.css as imported files
so a config edit under serve is a plain rewrite; filter the bundled OpenAPI document instead of
the generated pages; name the accepted SDK language ids; report the pre-2.0 migration at leaf
level and carry theme.colorMode over.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CSxkcBXCrw2T6KvYLy3riU
@sonarqubecloud

Copy link
Copy Markdown

@saeedjamshaid
saeedjamshaid merged commit 073d09a into dev Sep 21, 2026
15 of 16 checks passed
saeedjamshaid added a commit that referenced this pull request Sep 22, 2026
The paragraph said a portal with no `content/index.md` has no `/` and that the
fallback promised there was never built. It was, in #343: `src/routes/$.tsx`
answers the empty slug with a generated landing page, and `prerender-pages.ts`
always seeds `/`, so the static build emits an `index.html` either way.

What stands is that the page is minimal -- a title and a description, nothing
drawn from the content or the specifications -- which is what the sentence
about deciding it separately is now about. A plan saying a feature does not
exist is the kind of note someone acts on.

Reported by @mehnoorsiddiqui in review.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
saeedjamshaid added a commit that referenced this pull request Sep 22, 2026
The "Upgrading from 1.x" section names the file and its three blocks, and
the generated command blocks carry the new descriptions. The neighbouring
plans record the move: the portal-config plan loses `sdks.languages` for
the shared top-level block, gets `apimatic.schema.json`, and its claim that
PR #343 carries the breaking-change footer is corrected. The
apimatic-config plan gains the release-note and PR-description drafts for
the last PR of the series and marks every step done.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
github-actions Bot pushed a commit that referenced this pull request Sep 28, 2026
# [2.0.0-beta.1](v1.5.0...v2.0.0-beta.1) (2026-09-28)

* build!: move to Node 24 and TypeScript 7 ([#347](#347)) ([2528d6e](2528d6e))
* feat(quickstart)!: one funnel, and the language question ([#363](#363)) ([c270135](c270135)), closes [#361](#361)
* feat(sdk)!: retire v3 generation and let a publish record itself ([#359](#359)) ([c48f91b](c48f91b)), closes [#358](#358) [#358](#358) [apimatic-io#2233](https://github.com/apimatic-io/issues/2233)

### Bug Fixes

* **config:** keep the package configuration a publish did not write ([#392](#392)) ([bc52735](bc52735))
* **config:** nest publishing under the language ([#350](#350)) ([fbacf74](fbacf74))
* **deps:** take fumadocs 16.15.15, which fixes bold text in a built portal ([#388](#388)) ([fa178ab](fa178ab))
* **plugin:** ask only for the plugin's identity when the languages are recorded ([#389](#389)) ([d721819](d721819)), closes [apimatic/apimatic-io#2258](https://github.com/apimatic/apimatic-io/issues/2258)
* **portal:** bundle the Geist fonts instead of loading them from Google Fonts ([#366](#366)) ([95f2f7e](95f2f7e))
* **portal:** drop the TypeScript definitions panel from operation pages ([#353](#353)) ([042eb89](042eb89))
* **portal:** give a run the temp directory the filesystem names, not an alias ([#377](#377)) ([d71d23f](d71d23f))
* **portal:** keep the encoding a page slug carries ([#396](#396)) ([0bec5f4](0bec5f4)), closes [apimatic/apimatic-io#2270](https://github.com/apimatic/apimatic-io/issues/2270)
* **portal:** keep the header's links on one line ([#398](#398)) ([40dc472](40dc472))
* **portal:** keep the portal name on one line in the header ([#395](#395)) ([f3d352c](f3d352c))
* **portal:** keep the preview starting until its first page answers ([#399](#399)) ([5359442](5359442))
* **portal:** name the operations a shared page comes from, in words a spec's author knows ([#387](#387)) ([01197a7](01197a7))
* **portal:** offer the page actions on API reference pages ([#382](#382)) ([22f4873](22f4873)), closes [apimatic/apimatic-io#2247](https://github.com/apimatic/apimatic-io/issues/2247) [#383](#383)
* **portal:** refuse tag and operationId names a URL or a Windows folder cannot hold ([#400](#400)) ([0ea01b2](0ea01b2)), closes [apimatic/apimatic-io#2262](https://github.com/apimatic/apimatic-io/issues/2262) [#396](#396)
* **portal:** render a request body in a media type Fumadocs has no adapter for ([#386](#386)) ([9f89ad3](9f89ad3))
* **portal:** render an operation whose response example has no value ([#373](#373)) ([1dc84ad](1dc84ad))
* **portal:** render split OpenAPI specifications correctly ([#354](#354)) ([d97cda6](d97cda6))
* **portal:** stop printing an operation's raw description under its title ([#383](#383)) ([25b9f6e](25b9f6e)), closes [apimatic/apimatic-io#2242](https://github.com/apimatic/apimatic-io/issues/2242)
* **quickstart:** download the sample spec from its new openapi.json path ([#401](#401)) ([ebc6743](ebc6743))
* the release test's high and medium findings ([#375](#375)) ([7fe41e1](7fe41e1))

### Features

* code samples support ([#352](#352)) ([7b82568](7b82568))
* **config:** replace portal.json and plugin-config.json with apimatic.json ([#348](#348)) ([506f67f](506f67f))
* local plugin generation ([#358](#358)) ([5ba70de](5ba70de)), closes [#351](#351) [#346](#346) [#348](#348) [#318](#318)
* **portal:** build the documentation portal locally with Fumadocs ([#343](#343)) ([073d09a](073d09a))
* **portal:** build the SDK and Context Plugin pages from real templates and the portal artifacts ([#369](#369)) ([0efd7b3](0efd7b3))
* **portal:** fetch the artifacts from /portal-artifacts ([#361](#361)) ([0e823f3](0e823f3))
* **portal:** generate the SDK and context plugin pages ([#360](#360)) ([338b14a](338b14a))
* **portal:** give the portal block its v2 shape, with branding and tabs ([#355](#355)) ([d0fb189](d0fb189))
* **portal:** lead each SDK card with its install command ([#376](#376)) ([119e9d6](119e9d6)), closes [#375](#375)
* **portal:** let the root nav.json decide the tabs, and fix the nav.json QA findings ([#371](#371)) ([ecf7666](ecf7666))
* **portal:** order the sidebar from nav.json ([#346](#346)) ([306e4f9](306e4f9))
* **portal:** point to api transform when src/spec has no OpenAPI 3.x document ([#364](#364)) ([eefc917](eefc917))
* **portal:** the SDK Overview design, on the SDKs, language and plugin pages ([#391](#391)) ([fd3fe34](fd3fe34))
* **quickstart:** suggest validating with an AI agent when a spec fails ([#385](#385)) ([36fd2bb](36fd2bb))

### Reverts

* **portal:** offer the page actions on API reference pages ([#382](#382)) ([#397](#397)) ([c087ef9](c087ef9)), closes [#388](#388) [apimatic/apimatic-io#2247](https://github.com/apimatic/apimatic-io/issues/2247)

### BREAKING CHANGES

* `apimatic sdk save-changes` is removed, along with
`--codegen-version`, `--stability`, `--track-changes` and `--skip-changes`
on `sdk generate` and `--codegen-version`, `--stability` and
`--update-plugin-config` on `sdk publish`. Java, Ruby, Go and PHP cannot be
generated until they reach v4.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(sdk): keep --stability, which outlived the version it came with

Retiring v3 took `--stability` with it on the grounds that there was one
answer left. There is one answer *today*: v4 renders every language at beta
and they reach stable one at a time, so the level is a choice that survives
the version being fixed.

The flag is back on both commands, defaulting to stable as before, and the
level a language offers is a table rather than a constant — `stableLevels`
of one is answered without asking, and the interactive flow puts the
question back the moment a language has two. `sdk publish` names the level
in its summary only when the user chose it, read from oclif's parse
metadata, because `--stability stable` and the default are the same string
and the doc's block has no such row.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(sdk): keep --codegen-version, with one version in it

Retiring v3 removed the flag along with the version, on the grounds that a
choice of one is not a choice. But the flag is how a caller says which
generator they want, and there will be a v5: removing it makes that move a
release that changes what people get, rather than a value they pass.

`CodeGenerationVersion` now holds `V4` alone, and stays an enum so the next
one is a line in it. The flag is back on both commands, accepting `v4`,
defaulting to `v4`, and refusing `v3` by name rather than generating
something else quietly. `sdk publish` names it in its telemetry again.

It is not written down. `codegenVersion` left `apimatic.json` when the
service took over deciding the generator, so the flag says what to generate
with and nothing records what was used.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(config): drop codegenVersion from the published schema
* `apimatic quickstart` no longer offers a choice between a
portal and an SDK. It always builds a portal, and `apimatic sdk generate`
is the command for an SDK alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(quickstart): ask which languages the portal covers

The one question quickstart asks besides the spec. Every language a plugin
can carry comes up checked, because a first portal covering everything it
can is what a single Enter should give, and the four it cannot are named in
the prompt rather than left out in silence.

The answer is written to `apimatic.json` through `recordLanguages` before
anything is built, because that file is what says which SDKs a portal
documents — every command after this one reads it rather than the answer
that produced it.

`languageLabel` moves to `types/sdk/generate.ts`, where both prompts that
name a language can reach it, and TypeScript is spelled the way its own
project spells it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(quickstart): name the plugin after the folder, without asking

The `plugin` block is what asks the service for a context plugin, so
quickstart has to write one — and the journey allows it no question about
plugins. The identity comes from the directory the user is standing in.

A folder name is not a plugin id. `Acme Payments` and `acme_payments` are
ordinary names and neither is the kebab-case `pluginId` has to be, so the
id is made from the name while the name itself is kept as typed — the id
goes to `gh repo create`, the name is what a reader sees. A directory whose
name survives none of that falls back, because writing an id the config
would then refuse is worse than not deriving one.

`0.1.0` for the version: every plugin starts somewhere.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(quickstart): keep generated files out of the repository

Every command that builds a portal writes `src/static/sdk/`,
`src/static/plugin.zip` and an expanded `./plugin` into the project. None
of it belongs in a repository, and `portal serve` writes it too — so
without this a preview leaves a clean checkout dirty.

`/plugin/` is the one that matters. `plugin publish` runs `git init` inside
that directory and pushes it as its own repository; a parent tracking it
would nest one repository inside another.

Quickstart writes the entries because quickstart is what makes the project.
It appends what is missing and rewrites nothing, so a `.gitignore` the user
wrote survives and re-running in an adopted directory stacks no duplicates.

The paths are ignored here before anything on this branch produces them:
the placement lands with the `/portal-artifacts` call, and a user whose
first build dirties their tree has already been failed by then.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(quickstart): end in the preview again, and say the coming languages under the answer

Two things, both about where a line belongs.

The rebase onto dev quietly took dev's ending for the wizard -- the one that
stops at the scaffold because "the wizard does not ask for the project's SDK
languages yet". It does, three lines above that comment. The handoff to
`portal serve`, and the `onAfterServe` the serve action took for it, had both
gone; nothing failed, because no test described the funnel's own ending. One
does now.

`Next Steps` loses its first two items with them. It told the user to name
their SDK languages and then start a preview; the wizard has just done both.
What is left is what to change now that the portal is on screen.

The coming-soon languages move out of the multiselect's message and under the
answer, in grey. Inside the message they rendered above the languages the user
had picked, in the green a submitted answer is drawn in, reading like a fourth
option rather than a footnote to the three.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(portal): give quickstart the api transform hint, with the format it needs

Quickstart checks the spec itself before it writes the project, so the
transform hint portal generate and portal serve now give never reached it.
A Postman collection or a RAML file was told only that it was not an
OpenAPI document, with nothing to do about it.

Both now end on one sentence, which names --format=openapi3yaml: the
transform refuses to run without a format, so the command as printed
before would have failed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(quickstart): ignore only what is still generated into the project
* engines.node is now >=24.0.0. Node 22 is no longer supported.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(sdk): build the validation service once configDir is assigned

`validationService` was initialised from `this.configDir` in a property
initialiser, but `configDir` is a constructor parameter property. The code only
worked because TypeScript's downlevel emit assigns parameter properties before
property initialisers; under the native class-field semantics that a modern
target selects, `this.configDir` is still undefined at that point and the
service would be constructed with no config directory.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* build: compile with TypeScript 7, keeping TypeScript 6 for linting

TypeScript 7 is the native compiler. On this repo it builds `src` in ~1.3s
against ~4.7s, and type-checks the suite in ~1.0s against ~4.8s. Emitted
output is unchanged apart from sourcemaps, temp-variable naming and
declaration ordering.

typescript-eslint cannot run on it: every published version, canary included,
declares `typescript >=4.8.4 <6.1.0` and throws at module init on TS 7, which
would take out `lint`, `posttest` and the pre-commit hook. So the two sit side
by side in Microsoft's documented arrangement — `tsc` is TypeScript 7 and
builds, bare `typescript` is the TypeScript 6 shim that only the linter loads.
Naming them this way rather than aliasing TypeScript 7 keeps the compiler each
script gets written down, and survives `pnpm update --latest`.

TypeScript 6 stopped sweeping node_modules/@types, so the test project names
the globals the suite needs; the source project resolves its types through
`node:` imports and needs no list.

`target` moves to es2024 now that the engine floor is Node 24, which also
selects native class-field semantics. `ts-node` goes: it drives the classic
compiler API, which TypeScript 7 no longer ships, and the suite runs on tsx.
`@types/mocha` was six years behind mocha and had to be named explicitly.

The `typescript.tsdk` pin is dropped. It is deprecated in favour of
`js/ts.tsdk.path`, inert unless a developer opts in by hand, and would now
point at a TypeScript 6 build within a patch of what VS Code already bundles.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* ci: test on Node 24 and 26, and release on 24

The engine floor is Node 24, so the 22 legs go. The second leg is 26 rather
than the floor alone: 26 becomes LTS on 2026-10-28, inside this major's life,
so it is what most developers will be running. The job count is unchanged.

Releasing on Node 24 drops the `npm install -g npm@latest` step, whose only
purpose was that Node 22 bundles npm 10.9.8 while OIDC trusted publishing
needs 11.5.1. Node 24 bundles 11.19.0. The constraint moves to a comment on
the matrix, since pinning that job back to 22 would silently break publishing.

`npm-tag-latest.yml` carried a `node: [ '14' ]` matrix that no step consumed;
the job never sets up Node at all.

The standalone pnpm in test.yml is kept rather than reverted to corepack. Its
original reason is gone, but it is a SHA-pinned executable that needs no Node
of its own, where corepack would resolve pnpm from the registry at run time and
need the three-step setup-node dance the other workflows use. The comment now
states the reason that actually holds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(vscode): repair the CLI debug configuration

`preLaunchTask` named a task the TypeScript extension provides, and that
provider is not registered when the extension stands down for TypeScript 7, so
the configuration would fail to start. The npm task provider supplies `build`
regardless, and still rebuilds before launching.

`program` pointed at `bin/run`, deleted in the ESM migration; it only launched
because Node appends `.js`. The forty commented-out argument lines carried
another machine's absolute paths.

`.gitignore` re-ignored `.vscode/launch.json` in its custom section, cancelling
the allowlist above it. Inert while the file is tracked, but it would have
refused a re-add.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: record the two-compiler toolchain and how to remove it

The `typescript` dependency no longer being the compiler is surprising enough
to look like a mistake, so state why it is there, which command gets which
compiler, and the condition for deleting the arrangement.

Also corrects the build commands, which documented `tsc -b` although the
project has no references and the scripts call plain `tsc`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* ci: remove unused action

* fix: broken test

* fix: mock fs broken in node 26

* ci: install pnpm standalone in the build and release workflows

Corepack is not distributed with Node 25 and newer, so the Node 26 leg of
check_build could not bootstrap pnpm. Also correct the release floor: npm
11.5.1, which trusted publishing needs, first ships in Node 24.5.0.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(hooks): check the running Node against the declared engine range

Read engines.node from the manifest oclif has already parsed and compare
with semver, so package.json is the single source of the floor and a
minor-version floor is honoured. Test the hook's behaviour on both sides
of the range, and hold the CLI's range to the Node floors the portal
template's dependencies declare.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* docs: name the e2e test that depends on the two-compiler arrangement

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* chore: untrack launch.json and drop leftover config

launch.json goes back to being a personal, ignored file. mocha.opts named
ts-node and has been unused since mocha 8. ESLint now skips Claude Code
worktrees under .claude.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(portal): bind the prerender preview server to 127.0.0.1

The prerender pass fetched pages from a preview server on `localhost`.
On macOS it listened on `::1` only; a stalled connect under load fell
back to 127.0.0.1 and was refused, failing the portal build.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 2.0.0-beta.1 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants