Skip to content

feat(portal): give the portal block its v2 shape, with branding and tabs - #355

Merged
saeedjamshaid merged 57 commits into
devfrom
saeedjamshaid/portal-config
Sep 24, 2026
Merged

saeedjamshaid merged 57 commits into
devfrom
saeedjamshaid/portal-config

Conversation

@saeedjamshaid

@saeedjamshaid saeedjamshaid commented Sep 23, 2026 •

Copy link
Copy Markdown
Collaborator

The portal block of src/apimatic.json takes the shape it keeps for 2.0: site, brand, navigation and ai, nine settings in all. A portal carries its name, address and description, a logo per colour mode, a favicon, a primary colour, an optional forced colour mode and header links. The top level of the site renders as tabs taken from nav.json. The spec is .ai/plans/portal-config.md; its section 15 lists what was cut for the first release and why.

The block

Setting What it does Default
site.name The portal's name in the header and page titles The spec's title; required when spec/ holds several
site.url The origin the portal is hosted at, for canonical links, the sitemap and robots.txt None, so none of those
site.description The home page subtitle and the page description The first paragraph of the spec's description
brand.logo One image, or one per colour mode, from src/static/ None
brand.favicon The browser-tab icon, from src/static/ The light logo
brand.colors.primary #rgb or #rrggbb, once or per mode; the text on it is picked for contrast The theme's
brand.colorMode light, dark or both; a forced mode hides the switch both
navigation.links Header links, { label, url }; another site opens in a new tab None
ai.pageActions Each page's "Open in ChatGPT / Claude" menu On

Fixed rather than configurable in this release: Fumadocs' notebook layout with the tabs in the header, the neutral theme, Geist and Geist Mono, and an API reference grouped by tag that documents deprecated operations and leaves out those marked x-internal: true. Each is what its setting's default was, so any of them can come back as a setting without changing a portal that leaves it out.

What changes for a user

  • The block is nested. The flat keys it had on dev (title, siteUrl, aiPageActions, …) are reported as unknown settings, with no hint; that block never shipped. Every problem is reported at once, each by its dotted path.
  • A portal needs a languages entry. At least one is required, for example "languages": { "typescript": {} }. Nothing writes it for a portal project yet, so quickstart stops after the scaffold and shows the entry to add rather than starting a preview that would refuse the project.
  • Tabs:
    • Home, Guides, and the API reference.
    • A top-level folder whose nav.json sets "root": true gets a tab of its own.
    • apimatic:pages is renamed apimatic:sdks.
  • The scaffold writes the populated block. It includes $schema, so editors complete and check the file from apimatic.schema.json, which ships in the package and covers the whole file.
  • portal serve re-applies edits to the portal block. An edit a build would refuse is reported, and the preview keeps what it last accepted. A newly created src/static is reported as needing a restart.
  • Internal operations are left out. They are removed from the document before any page is built, so they appear in no page, sidebar, payload, sitemap or llms.txt. Two operations sharing an operationId, or one listing a tag twice, would put two pages at one path, and are refused rather than one page being dropped.

Commits

  1. The plan, rewritten for apimatic.json.
  2. The nested block: a value object per namespace, Color, PortalLanguages, and the name and description taken from the spec.
  3. apimatic.schema.json, with a test that runs the schema and the parser over the same fixtures.
  4. Generated files: portal.identity.json and theme.css replace the literal substituted into portal.ts, and the template applies the block.
  5. Tabs: a root page-tree transformer builds one root folder per tab, an explicit tab list is passed to the layout, and the CLI validates root.
  6. The API reference: the filter runs over the document bundleSpecification (fix(portal): render split OpenAPI specifications correctly #354) returns, before Fumadocs sees it.
  7. Serve re-apply: FileWatchService and the serve action.
  8. Scaffold, docs and e2e: the populated scaffold, README notes, a second e2e build that brands everything, and dated amendments to the plans this one changes.
  9. Review fixes (4cdbefe): input validation, the filter's $ref handling and emptied tags, a tab opening on the wrong page, and the serve watcher.
  10. Code-quality pass (17 commits): every SonarCloud issue resolved, PreviewConfig and FilePath.isEqual extracted, the CLI and template types held equal by the compiler, comments cut to what the code cannot say, and a test that failed wherever colour output is on.
  11. The first-release trim (7 commits): the primary colour narrowed to hex, then the layout, colour preset, fonts, home-page call to action, api options and raw colour tokens removed, each with its schema, tests and plan entry.
  12. Review of the trim (7 commits): the README's upgrade notes, the draft release notes and the other plans no longer name cut settings; a unit test holds app.css to importing the theme before the generated stylesheet; and the shared-page refusal names a repeated tag as well as a repeated operationId.
  13. The first CI run (2 commits), once the PR was based on dev:
    • The watcher now watches apimatic.json's directory by its real path. On Windows, libuv aborts the whole process at the first event under a directory given by its 8.3 short name, such as the runners' C:\Users\RUNNER~1\..., and portal serve would crash the same way. A Windows-only test watches through a short name.
    • The watch tests now let a watch go live before saving, because macOS can drop a save made the moment the watch starts.

How it fits #354

This branch was built on #354, and is rebased onto dev now that #354 is squash-merged there. openApiSection hands Fumadocs withoutInternalOperations(await bundleSpecification(file)) through the function form of input, so there is one server. With #354's inlining, operations behind a path-item $ref get pages whatever the filter leaves out. #354's own test calls openApiSection with the signature this PR gives it.

Verification

  • CI: all ten checks pass. That covers the Tests matrix on Ubuntu, Windows and macOS with Node 24 and 26 (986 passing with the e2e builds), both sample builds and SonarCloud.
  • pnpm test, after the rebase onto dev: 959 passing, 28 pending. The pending tests are the e2e cases, which need APIMATIC_E2E=1.
  • Lint and type-checks: repo-wide ESLint is clean, and both type-checks pass.
  • APIMATIC_E2E=1, after the rebase: 27 passing, 1 skipped as before. There are two real builds:
    • The default fixture is type-checked, and checks the tabs in the header, Geist and the neutral theme, and that the browser bundle carries the identity file and nothing from the build-only config.
    • A branded fixture forces dark mode and sets a primary, two logos, a favicon and a header link, with no content directory, so it also covers the fallback home page. Its spec's deprecated operation is documented and its internal one left out.
  • theme.css against Tailwind: a unit test compiles it after the neutral theme and checks each mode's primary comes after the theme's own rules.
  • SonarCloud: no open issues.
  • Headless Chrome, checked while building (before the trim): tabs with the active tab following the page, forced light and dark modes, the primary winning in both, and a running portal serve picking up a rewritten identity and theme.
  • Sample repository: apimatic/sample-docs-as-code-portal builds 22 pages with this branch after the trim. Its v2 branch is moved to this layout, trimmed block included, in Make src/ on v2 the starting point for the CLI 2 quickstart sample-docs-as-code-portal#12.

🤖 Generated with Claude Code

saeedjamshaid added a commit to apimatic/sample-docs-as-code-portal that referenced this pull request Sep 24, 2026
apimatic/apimatic-cli#355 trims the portal block to the settings a
portal needs for its name, address and brand, and refuses the rest as
unknown keys. src/apimatic.json loses the colour preset, the fonts,
the layout, the home page's call to action, the api options and the
advanced tokens; every one held its default except the call to action,
and the home page already links to the authentication guide. The
README no longer lists fonts and layout among what the file sets.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@saeedjamshaid saeedjamshaid changed the title feat(portal): give the portal block its v2 shape, with branding, tabs and API options feat(portal): give the portal block its v2 shape, with branding and tabs Sep 24, 2026
saeedjamshaid and others added 26 commits September 24, 2026 13:13
The plan was written against portal.json and plugin-config.json, before
apimatic.json (#348), the nav.json work (#346) and the publishing level
of language entries (#350) landed. Rewritten in place rather than amended
again:

- The block is the `portal` block of src/apimatic.json, nested into
  site, brand, navigation, home, api, ai and advanced. The flat dev keys
  are plain unknown keys, with no near-miss hints.
- The portal requires the shared top-level `languages` block with at
  least one entry, written by hand for now; quickstart stops after the
  scaffold instead of serving a project that cannot validate.
- Tabs come from the root nav.json rather than a sections list:
  apimatic:api and apimatic:sdks (renamed from apimatic:pages) place the
  API and SDK tabs, a top-level folder opts in with "root": true, and the
  Home, Guides and SDKs labels are fixed.
- apimatic.schema.json covers the whole file, strict only for `portal`.
- Delivered as one PR in seven reviewed steps.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The `portal` block of src/apimatic.json becomes site, brand, navigation,
home, api, ai and advanced, each parsed by its own value object under
src/types/portal/config/. Every problem is reported at once by its dotted
path; the flat keys the block had on dev are plain unknown keys, with no
hint.

The portal now requires the top-level `languages` block with at least one
entry, keyed by an SDK language, and reads that block's findings along
with its own. An entry with no publishing record counts. A malformed
plugin block still never fails a build.

The site's name and description default to what the only specification
says about itself, the description stopping at its first blank line; with
several specifications the name is required, so the specifications are
now read before the block is judged. The logo, a dark logo and the
favicon are all checked for on disk, and every missing one is reported
with the setting that names it.

Color parses the primary's accepted forms and picks a contrasting
foreground, and the font table carries each family's Google Fonts weight
axis, checked against the service. The template is untouched: the
browser identity keeps its fields until the template reads the new keys.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
apimatic.schema.json covers the whole file: the root and the plugin and
languages blocks stay lenient, as the CLI reads them, while the portal
block refuses unknown keys at every level. It ships in the package and is
addressed by APIMATIC_SCHEMA_URL. A conformance test runs the schema and
the parsers over the same fixtures, with the deliberate gaps (colour
channel ranges, the several-specs name rule, the one-language minimum)
pinned by their own cases.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The CLI now writes two files into the build project instead of
substituting the identity into portal.ts: portal.identity.json, which the
browser imports whole and so holds nothing that addresses the build
machine, and src/styles/theme.css, which PortalStylesheet renders from the
block: the colour preset, the glass styles, the two font families, and
each mode's primary colour with a contrast-picked foreground followed by
the raw token overrides. portal.config.json keeps only what the build
set-up reads, and gains the API options for a later step.

The template applies the rest: a layout module that picks docs,
notebook, notebook with the tabs in the navbar, or glass, together with
that layout's page components; both logos; the favicon with its type; the
Google Fonts stylesheet; header links; a colour mode that hides the switch
and the hotkey when forced; the home page's call to action; and the page
actions setting under its new name.

A token value is written into the stylesheet as it stands, so the parser
and the schema now refuse one holding ';', '{', '}' or a comment.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A second page-tree transformer regroups the root, once nav.json has
ordered it, into one root folder per tab: Home for the index page, the
API reference, SDKs for the generated pages, a tab of its own for each
top-level folder whose nav.json sets "root": true, and Guides for
everything else. Tabs are ordered by where each one's first node sits,
with Home leading unless the file places the index page itself. A tab's
index page moves to the top of its sidebar, and a project without an
index page gets a node for the fallback home page so it shows a tab bar
too. The layouts are given an explicit tab list, since Fumadocs' own
skips a tab that holds only folders, as the reference always does.

The CLI accepts 'root' only on a folder directly under content/, other
than content/api/, and refuses an 'index' entry in a tab's nav.json. The
walk now tells a top-level folder from a nested one. The injected-pages
token is renamed apimatic:sdks; the old name is an unknown token.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
openApiSection takes the API options. The document bundleSpecification
makes is filtered before Fumadocs sees it: the operations the block
leaves out (deprecated ones unless showDeprecated, those marked
x-internal unless showInternal) are removed from paths and webhooks,
and a path item left with none is dropped. Filtering the document
rather than the pages keeps a hidden operation out of everything built
from it: its page, its sidebar row, an emptied tag folder, and every
other page's payload. groupBy passes through to Fumadocs in place of
the fixed 'tag', and the prerender list goes through the same function,
so the URLs it emits follow both settings.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A colour or font tweak used to need the preview restarted. portal serve
now watches src/apimatic.json and, on each save, re-runs the config half
of the source resolution: the portal and languages rules, the existence
of every file the block names, and the site name and description taken
from the specifications read at startup. An edit is therefore accepted
exactly when a build would accept it. An accepted edit rewrites
portal.identity.json and theme.css, and the dev server reloads them. A
refused one is reported as portal generate would report it, and the
preview keeps what it last accepted. Each file is written only when its
contents change, so a plugin command rewriting its own block changes
nothing and prints nothing. A change to portal.api says a restart is
needed, since the reference pages and the prerender list are made once.

The watch is on the directory, so an editor that saves by renaming a new
file over the old one keeps being seen. A save's events are gathered
before it is reported, one save is handled at a time, and closing waits
for a save still being handled.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The quickstart scaffold writes $schema first, then schemaVersion and the
portal block with every default spelled out, so the file shows what can
be set and an editor completes and checks the rest. Nothing in the wizard
asks for the project's SDK languages yet, and a portal is not built
without one, so the wizard no longer starts a preview that would refuse
the project it just wrote. It keeps its Node-version and entitlement
checks, then ends with next steps: the languages entry to add, what it
also means for the plugin commands, and the portal serve command to run.
The serve action loses the hook only quickstart used.

The README's upgrade notes name the nested block, the languages
requirement and the tabs, and the serve description is updated. A second
end-to-end build brands a portal in every way the block allows: glass,
forced dark, a preset with a primary, fonts, links, a call to action on
the fallback home page, and hidden deprecated and internal operations.
The default build now also checks the tabs in the header, the default
theme, and that the browser gets the identity and nothing from the
build-only config. The plans this one changes carry dated amendments.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Validation let through values that break the portal after it builds:
- A token value with an unclosed parenthesis, bracket or quote, or a
  trailing backslash, ran past its declaration and failed the build or
  dropped both colour-mode blocks. A value is now made of the characters
  every CSS colour is written with, and its parentheses must balance.
- static//logo.png found the file and was served as //logo.png, on
  another host. Every name on a static path must be there, and each is
  escaped in the URL.
- A file named in another case passed on Windows and macOS and 404s on
  the host. It is now reported, with its spelling on disk.
- A link path is resolved as a browser resolves it, so /\host is
  refused, and a full address must carry its //.
- The primary colour follows CSS's own rules for the comma and space
  forms, and takes any hue unit.
The schema follows each rule it can express.

The API filter follows a $ref to the end of its chain, reads the keys
beside it, and drops a tag only hidden operations carried, since every
page's payload carries the tags. Grouped by route, the operations on /
reach the sidebar, and a path and a webhook sharing a page are refused.

A tab no longer opens on the page with the same path in the tab being
left, and Home leads when index names a folder. Token overrides reach the sidebar
under every preset. portal serve reads the file again once it watches
it, coalesces saves, says when the watch fails or a new static
directory needs a restart, and stops watching before it says it stops.
A prerelease scaffold names its own version in $schema, which jsDelivr
serves, where @2 resolves to nothing until 2.0.0 ships.

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

pathItemShown and tagsUsed each walked the fixed methods and
additionalOperations by hand, which put both over SonarCloud's cognitive
complexity limit (22 and 27 against 15). operationsOf now lists them for
both, and the copy without hidden operations is built by withoutHidden.
Behaviour is unchanged; a test covers an emptied additionalOperations.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
firstPageUrl in tabs.ts chose the next URL with a nested ternary, which
SonarCloud flags (S3358); firstPageIn in navigation.ts had the same shape.
Both now return early. Behaviour is unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The missing-static-file check compared two paths through toString(), which
the value-object conventions rule out and SonarCloud flags for the null
check beside it (S6582). FilePath.isEqual compares the directory and the
name exactly, so a name that differs only in case is still reported.

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

SonarCloud flags the nested ternary in balanced() (S3358). It now branches
on each parenthesis. Behaviour is unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
SonarCloud prefers includes() to a some() that only checks for one value
(S7765). Behaviour is unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Three messages quoted their allowed values with a template literal nested
in the message's own, which SonarCloud flags (S4624) in two of them.
quotedList in config/fields.ts now does it for all three. The messages are
unchanged.

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

watchConfig kept the last accepted config and whether the last save was
refused in closures, and decided in the action whether an edit needs a
restart, names static files the preview does not serve, or should be
announced as applied. PreviewConfig in src/types/portal now holds that
state and answers those questions, with unit tests of its own; the action
is left reading the file, applying it and prompting. Behaviour is unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The case-mismatch test expected a quote right after "which is spelt ", but
picocolors colours output on Windows and in CI whatever the terminal, so a
colour code sits there and the test failed wherever colour was on. The
report is now read with its escape codes stripped.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The quickstart prompts built the path of src/apimatic.json themselves to
print it in the next steps. PortalSourceContext.scaffold now answers with
the file it wrote, as a context's write operations do, and the action
passes it on.

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

The file watch drops whatever its handler throws, so a fault that no Result
carried, such as a directory turning unreadable while the static files are
checked, left the preview stale without a word. The serve action now
catches it and reports it as a change it could not apply; the watch keeps
running.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The template's Portal interface and ApiOptions were held to what the CLI
writes only by reading the template source with regexes for top-level keys
and union members, so a field renamed inside logo, favicon, a link or api
went unnoticed until an e2e build. The identity types now sit in
portal-types.ts, which imports no generated file, and a test asserts both
types are identical to the CLI's, nested fields and union members included,
where pretest's tsc checks it. BuildIdentity is now picked from Portal
rather than declared again.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The one caller that set it read reportSourceProblem(problem, directory,
false), which said nothing about what false turned off. The flag is now an
option named at the call site, still defaulting to true.

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Fonts.googleFontsUrl returned a string while SiteConfig.origin returns the
other absolute address the block derives as a UrlPath. Both are now rich
types, unwrapped where the identity is written out.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
isWebAddress requires the address to start with http:// or https://, so
testing that it does not start with '/' first changed nothing.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The missing-languages error and quickstart's next steps each spelt out the
same example entry. LANGUAGES_EXAMPLE in portal-languages.ts now serves
both, so they cannot drift apart. The text is unchanged.

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

The project's rule is to comment only a reason the code cannot show. This
branch's comments often restated the code (a CSS font-family accessor
documented as the CSS font-family value, a namespace class headed by its
settings path), or told the history of a fix ("was not enough", "without
this it shipped"). Those are gone or cut down to the reason, across
src/ and portal-template/. Every constraint and workaround stays: the
jsDelivr prerelease rule, the .invalid origin, the #nd-sidebar specificity,
Fumadocs' $ref projection, and the like. Only comment lines change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
saeedjamshaid and others added 2 commits September 24, 2026 15:36
One SDKs tab from a generated sdks/ folder: an overview, a page per
configured language in the order written, and a context plugin page when
apimatic.json has a plugin block. Placeholder templates in a shipped
portal-pages/ directory, rendered by {{key}} substitution before MDX ever
sees them, compiled as a second Fumadocs collection over a relative
'generated' literal so no build path is published, positioned by
apimatic:sdks, prerendered, and regenerated under portal serve.
Implementation waits for #355 to merge.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The plan put the context plugin page at /sdks/context-plugin, inside the
SDKs tab. It now has its own folder and tab at /context-plugin, positioned
with a new apimatic:plugin token, and the synthetic SDKs tab is dropped
because the CLI writes only folders.

Each token claims its own section's folder, unnamed sections keep a fixed
order before the API reference, and /context-plugin is reserved like /sdks.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Comment thread src/types/portal/config/link.ts
Comment thread portal-template/src/lib/openapi-filter.ts
Comment thread src/infrastructure/portal-project-service.ts Outdated
Comment thread src/types/portal/config/fields.ts Outdated
Comment thread src/types/portal-source-context.ts Outdated
Comment thread src/types/portal-source-context.ts
saeedjamshaid and others added 13 commits September 24, 2026 17:42
…site

A link such as /.//evil.com stays on the portal when resolved, but its
dot segment drops away and leaves //evil.com as the path the header
writes out, which a browser opens as another host. Refuse a resolved
path that starts with two slashes. The schema's pattern cannot follow
dot segments, so it refuses an empty segment anywhere in the path.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A group tag carries no operations of its own, so it was never counted
as emptied: with every tag under it hidden, its name and description
still rode along in every page's payload. Drop a group no remaining
operation carries once every tag under it has gone, up the chain, and
from x-tagGroups too.

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

applyConfig rewrote portal.identity.json and theme.css in place, which
truncates each before writing it, so the watching dev server could read
one empty or half-written. Write each beside its target and rename it
over, as replaceContents does. The build-time write stays as it was: it
runs before the dev server starts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The file was committed without the pre-commit hook, so the hook would
fold this reformat into the next change to it. No test changes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
apimatic.json's document, the portal block's fields and the OpenAPI
document each tested for a JSON object on their own. They now share
isJsonObject from utils/json-utils.

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

The payload slimmer listed the operation methods without 3.2's query,
and it keeps any key it does not know as a method, so a query
operation and everything under additionalOperations rode along, with
the schemas they reach, in the payload of every other page at that
path. It now takes the filter's list from one module, and narrows
additionalOperations to what the page renders.

The template's three JSON object checks become one as well.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
PortalSourceContext split a file's display path into strings to walk
the directories, then rebuilt a FileName from the name it matched,
which the value-object conventions rule out. The walk now lives in
FileService.spelledOnDisk, which carries the matched DirectoryPath and
FileName through rather than rebuilding them, and keeps its string
comparisons at the file system boundary.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
PortalLanguages worked out whether each language is published, and
re-checked the shapes the document had already validated to do it, but
nothing asks. PortalConfig's siteDescription, siteOrigin,
navigationSettings and aiSettings were read only by tests: their values
reach the portal through identity(), which the tests now read instead.
PortalLanguages keeps all(), which the generated pages read.

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

The parser refused a resolved path starting with two slashes while the
schema refused any empty segment, so /docs//page built but was marked
invalid in an editor. The parser now refuses the empty segment itself,
read as a browser reads it, which also covers every dot-segment route
to //host; the two rules agree on every path the fuzz tried.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A tag that might go stayed while any tag not yet gone named it as its
parent, itself included, so two emptied groups naming each other, or
one naming itself, kept their names and descriptions in every payload.
The tags that stay are now grown upward from those that stay regardless.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Fumadocs builds no page for an operation under additionalOperations, so
keeping the one a page renders was a branch nothing could reach.

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

Two object checks written out by hand were left behind by the shared one.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
As .ai/instructions.md asks: one line of why, and none that restates the
code.

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

Copy link
Copy Markdown

@saeedjamshaid
saeedjamshaid merged commit d0fb189 into dev Sep 24, 2026
10 checks passed
sohail2721 pushed a commit that referenced this pull request Sep 24, 2026
The upgrade notes collided: #355 rewrote the `portal` block for its v2 shape
while this branch still described the v1 one. Dev's text wins, because that is
what the block is now, and the one thing only this branch knows is folded into
it -- `plugin generate` writes the `languages` block too, not just
`sdk publish`.

Co-Authored-By: Claude Opus 5 (1M context) <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.

2 participants