From 4a70d0669667d0fd28e7b58dae72261f1145e38b Mon Sep 17 00:00:00 2001 From: Muhammad Saeed Date: Mon, 26 Oct 2026 14:52:09 +0500 Subject: [PATCH 1/7] chore: retire CHANGELOG.md and the committed version (#453) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #420. 2.0.0 has shipped and `main` is back in `dev` (52b9c399), so this merges without conflicts. ## The change Nothing commits a version or a changelog back to a branch since #409, so both files would go stale: - **`CHANGELOG.md`** opens with a line pointing at GitHub Releases. It keeps the history up to 1.5.0 and drops the 2.0.0-beta.1 and 2.0.0-beta.2 sections, which are in GitHub Releases. - **`package.json`'s `version`** becomes `0.0.0-semantically-released`, the placeholder semantic-release's FAQ suggests. Otherwise it stays at `2.0.0-beta.2`. ## Checked - Everything in `CHANGELOG.md` from 1.5.0 down is unchanged. - `@semantic-release/npm` runs `npm version --no-git-tag-version --allow-same-version` before `npm publish`. On a copy of this `package.json`, that writes the real version. `oclif.manifest.json` is generated after that, by `prepack`. - A build from source: `apimatic --version` prints `@apimatic/cli/0.0.0-semantically-released`, and the User-Agent and telemetry report the same version. `npm pack` names the tarball `apimatic-cli-0.0.0-semantically-released.tgz`, and Portal automation tests find it with a `*.tgz` glob. ## Not in this PR `oclif readme` takes the version from `package.json` for the README's "See code" links and the `--version` example. Run without its `--version` flag, it now writes `blob/v0.0.0-semantically-released/…` links, which 404. Nothing runs it automatically; the README's command section was last regenerated at 1.3.1. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 346 +-------------------------------------------------- package.json | 2 +- 2 files changed, 2 insertions(+), 346 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2403b5fc5..067bf4f14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,348 +1,4 @@ -# [2.0.0-beta.2](https://github.com/apimatic/apimatic-cli/compare/v2.0.0-beta.1...v2.0.0-beta.2) (2026-09-29) - - -### Bug Fixes - -* **config:** point apimatic.json's $schema at the beta dist-tag ([#406](https://github.com/apimatic/apimatic-cli/issues/406)) ([74ad5d2](https://github.com/apimatic/apimatic-cli/commit/74ad5d2eb30a5f888abbb4209be445c846b50118)) -* **quickstart:** shorten the live preview and next steps notes ([#403](https://github.com/apimatic/apimatic-cli/issues/403)) ([45fcca1](https://github.com/apimatic/apimatic-cli/commit/45fcca1ad3c1a93803b67a9a0f9b0e93e3bb7db9)) - -# [2.0.0-beta.1](https://github.com/apimatic/apimatic-cli/compare/v1.5.0...v2.0.0-beta.1) (2026-09-28) - - -* build!: move to Node 24 and TypeScript 7 ([#347](https://github.com/apimatic/apimatic-cli/issues/347)) ([2528d6e](https://github.com/apimatic/apimatic-cli/commit/2528d6e643e29ac110919c4e04f92c99a103f500)) -* feat(quickstart)!: one funnel, and the language question ([#363](https://github.com/apimatic/apimatic-cli/issues/363)) ([c270135](https://github.com/apimatic/apimatic-cli/commit/c270135df0c743baba3bf5e63f16f95260e1302a)), closes [#361](https://github.com/apimatic/apimatic-cli/issues/361) -* feat(sdk)!: retire v3 generation and let a publish record itself ([#359](https://github.com/apimatic/apimatic-cli/issues/359)) ([c48f91b](https://github.com/apimatic/apimatic-cli/commit/c48f91b548ae4f148449ddf3f010238edaba18a5)), closes [#358](https://github.com/apimatic/apimatic-cli/issues/358) [#358](https://github.com/apimatic/apimatic-cli/issues/358) [apimatic-io#2233](https://github.com/apimatic-io/issues/2233) - - -### Bug Fixes - -* **config:** keep the package configuration a publish did not write ([#392](https://github.com/apimatic/apimatic-cli/issues/392)) ([bc52735](https://github.com/apimatic/apimatic-cli/commit/bc5273588af0b8077fe67113dbc5de126ee2d2b3)) -* **config:** nest publishing under the language ([#350](https://github.com/apimatic/apimatic-cli/issues/350)) ([fbacf74](https://github.com/apimatic/apimatic-cli/commit/fbacf74e559fbf175b8853231cd5586197b21973)) -* **deps:** take fumadocs 16.15.15, which fixes bold text in a built portal ([#388](https://github.com/apimatic/apimatic-cli/issues/388)) ([fa178ab](https://github.com/apimatic/apimatic-cli/commit/fa178ab61b22a0bf1e051cf92788b3ca2726aea1)) -* **plugin:** ask only for the plugin's identity when the languages are recorded ([#389](https://github.com/apimatic/apimatic-cli/issues/389)) ([d721819](https://github.com/apimatic/apimatic-cli/commit/d721819fc02a6722480bf9bb65d52c939b82ef69)), 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](https://github.com/apimatic/apimatic-cli/issues/366)) ([95f2f7e](https://github.com/apimatic/apimatic-cli/commit/95f2f7edd48dd3266e4b6622976a6ec6fafa1db5)) -* **portal:** drop the TypeScript definitions panel from operation pages ([#353](https://github.com/apimatic/apimatic-cli/issues/353)) ([042eb89](https://github.com/apimatic/apimatic-cli/commit/042eb89519491ee8203151743ad0c5490ec9c6db)) -* **portal:** give a run the temp directory the filesystem names, not an alias ([#377](https://github.com/apimatic/apimatic-cli/issues/377)) ([d71d23f](https://github.com/apimatic/apimatic-cli/commit/d71d23f63c7b4c9e700b8e3d08fc6c378c04ff03)) -* **portal:** keep the encoding a page slug carries ([#396](https://github.com/apimatic/apimatic-cli/issues/396)) ([0bec5f4](https://github.com/apimatic/apimatic-cli/commit/0bec5f4e88d9749e525405084389676debc064b3)), closes [apimatic/apimatic-io#2270](https://github.com/apimatic/apimatic-io/issues/2270) -* **portal:** keep the header's links on one line ([#398](https://github.com/apimatic/apimatic-cli/issues/398)) ([40dc472](https://github.com/apimatic/apimatic-cli/commit/40dc472479b65bf5ecb2e949f2281d327f6ff354)) -* **portal:** keep the portal name on one line in the header ([#395](https://github.com/apimatic/apimatic-cli/issues/395)) ([f3d352c](https://github.com/apimatic/apimatic-cli/commit/f3d352c9446fa599904c5195560978f24cecd90e)) -* **portal:** keep the preview starting until its first page answers ([#399](https://github.com/apimatic/apimatic-cli/issues/399)) ([5359442](https://github.com/apimatic/apimatic-cli/commit/5359442013accefc4f3a9ce50d78896fca937fce)) -* **portal:** name the operations a shared page comes from, in words a spec's author knows ([#387](https://github.com/apimatic/apimatic-cli/issues/387)) ([01197a7](https://github.com/apimatic/apimatic-cli/commit/01197a7d994ff9a28eb48efc5a6ef4202bea6b6f)) -* **portal:** offer the page actions on API reference pages ([#382](https://github.com/apimatic/apimatic-cli/issues/382)) ([22f4873](https://github.com/apimatic/apimatic-cli/commit/22f4873607306ba27e0e2c2eb0e69451d6fac359)), closes [apimatic/apimatic-io#2247](https://github.com/apimatic/apimatic-io/issues/2247) [#383](https://github.com/apimatic/apimatic-cli/issues/383) -* **portal:** refuse tag and operationId names a URL or a Windows folder cannot hold ([#400](https://github.com/apimatic/apimatic-cli/issues/400)) ([0ea01b2](https://github.com/apimatic/apimatic-cli/commit/0ea01b2ce3537e434e95cbe765a59a67d167e16c)), closes [apimatic/apimatic-io#2262](https://github.com/apimatic/apimatic-io/issues/2262) [#396](https://github.com/apimatic/apimatic-cli/issues/396) -* **portal:** render a request body in a media type Fumadocs has no adapter for ([#386](https://github.com/apimatic/apimatic-cli/issues/386)) ([9f89ad3](https://github.com/apimatic/apimatic-cli/commit/9f89ad3d3f6296130d8178aabca62e56c1babf2e)) -* **portal:** render an operation whose response example has no value ([#373](https://github.com/apimatic/apimatic-cli/issues/373)) ([1dc84ad](https://github.com/apimatic/apimatic-cli/commit/1dc84ad797384e9d7cc55eb55192092b9b587379)) -* **portal:** render split OpenAPI specifications correctly ([#354](https://github.com/apimatic/apimatic-cli/issues/354)) ([d97cda6](https://github.com/apimatic/apimatic-cli/commit/d97cda690e967346fa6493edffb705edfea1fc44)) -* **portal:** stop printing an operation's raw description under its title ([#383](https://github.com/apimatic/apimatic-cli/issues/383)) ([25b9f6e](https://github.com/apimatic/apimatic-cli/commit/25b9f6e677e73df15659f6526f5c8fc3623e2b0b)), 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](https://github.com/apimatic/apimatic-cli/issues/401)) ([ebc6743](https://github.com/apimatic/apimatic-cli/commit/ebc67436b94d5084f0e0b0c502f3b58161a64334)) -* the release test's high and medium findings ([#375](https://github.com/apimatic/apimatic-cli/issues/375)) ([7fe41e1](https://github.com/apimatic/apimatic-cli/commit/7fe41e180230a3d491283fe74f9de9c8f9785939)) - - -### Features - -* code samples support ([#352](https://github.com/apimatic/apimatic-cli/issues/352)) ([7b82568](https://github.com/apimatic/apimatic-cli/commit/7b82568b228cd665b9f103a0c636ef77c00d7d92)) -* **config:** replace portal.json and plugin-config.json with apimatic.json ([#348](https://github.com/apimatic/apimatic-cli/issues/348)) ([506f67f](https://github.com/apimatic/apimatic-cli/commit/506f67f6e0b9c4ea7b79f356f3db7c5aa4cb96cd)) -* local plugin generation ([#358](https://github.com/apimatic/apimatic-cli/issues/358)) ([5ba70de](https://github.com/apimatic/apimatic-cli/commit/5ba70dedc757e4d1cee05eddc69c1f84d9ea8f07)), closes [#351](https://github.com/apimatic/apimatic-cli/issues/351) [#346](https://github.com/apimatic/apimatic-cli/issues/346) [#348](https://github.com/apimatic/apimatic-cli/issues/348) [#318](https://github.com/apimatic/apimatic-cli/issues/318) -* **portal:** build the documentation portal locally with Fumadocs ([#343](https://github.com/apimatic/apimatic-cli/issues/343)) ([073d09a](https://github.com/apimatic/apimatic-cli/commit/073d09aee9b0fe40dc9ee4cdae428bf7dc8cf075)) -* **portal:** build the SDK and Context Plugin pages from real templates and the portal artifacts ([#369](https://github.com/apimatic/apimatic-cli/issues/369)) ([0efd7b3](https://github.com/apimatic/apimatic-cli/commit/0efd7b3e4e49d887a621d9888c0944ea420f707f)) -* **portal:** fetch the artifacts from /portal-artifacts ([#361](https://github.com/apimatic/apimatic-cli/issues/361)) ([0e823f3](https://github.com/apimatic/apimatic-cli/commit/0e823f3d1f3fdcb464af13f78bf5c26327dcbd57)) -* **portal:** generate the SDK and context plugin pages ([#360](https://github.com/apimatic/apimatic-cli/issues/360)) ([338b14a](https://github.com/apimatic/apimatic-cli/commit/338b14a1de49b2887f2e08f42a246b99f99496b6)) -* **portal:** give the portal block its v2 shape, with branding and tabs ([#355](https://github.com/apimatic/apimatic-cli/issues/355)) ([d0fb189](https://github.com/apimatic/apimatic-cli/commit/d0fb189da70afcec949bc658287b653a39519306)) -* **portal:** lead each SDK card with its install command ([#376](https://github.com/apimatic/apimatic-cli/issues/376)) ([119e9d6](https://github.com/apimatic/apimatic-cli/commit/119e9d644c25f6465ad65224a47b22adb42e8ee8)), closes [#375](https://github.com/apimatic/apimatic-cli/issues/375) -* **portal:** let the root nav.json decide the tabs, and fix the nav.json QA findings ([#371](https://github.com/apimatic/apimatic-cli/issues/371)) ([ecf7666](https://github.com/apimatic/apimatic-cli/commit/ecf76668ec6e265c8afc7f36a3203c8687770d3b)) -* **portal:** order the sidebar from nav.json ([#346](https://github.com/apimatic/apimatic-cli/issues/346)) ([306e4f9](https://github.com/apimatic/apimatic-cli/commit/306e4f9a8cfb9fa0be2cd2807abe7f559df644ff)) -* **portal:** point to api transform when src/spec has no OpenAPI 3.x document ([#364](https://github.com/apimatic/apimatic-cli/issues/364)) ([eefc917](https://github.com/apimatic/apimatic-cli/commit/eefc9178d76c5863c5af6fb632463364716a7052)) -* **portal:** the SDK Overview design, on the SDKs, language and plugin pages ([#391](https://github.com/apimatic/apimatic-cli/issues/391)) ([fd3fe34](https://github.com/apimatic/apimatic-cli/commit/fd3fe3463def3c1650e4df658480982c4dfd2624)) -* **quickstart:** suggest validating with an AI agent when a spec fails ([#385](https://github.com/apimatic/apimatic-cli/issues/385)) ([36fd2bb](https://github.com/apimatic/apimatic-cli/commit/36fd2bb258b70cf20212ce64e8bee0cbc55addcd)) - - -### Reverts - -* **portal:** offer the page actions on API reference pages ([#382](https://github.com/apimatic/apimatic-cli/issues/382)) ([#397](https://github.com/apimatic/apimatic-cli/issues/397)) ([c087ef9](https://github.com/apimatic/apimatic-cli/commit/c087ef927e5e6919b5626dab24d203ebdd1f2fca)), closes [#388](https://github.com/apimatic/apimatic-cli/issues/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) - -* 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) - -* 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) - -* 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) - -* 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) - -* 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) - -* 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) - -* 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) - -* 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) - -* 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) - -* 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) - -* 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) - -* 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) - -* 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) - -* 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) - -* 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) - -* 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) - -* docs: name the e2e test that depends on the two-compiler arrangement - -Co-Authored-By: Claude Opus 5.5 (1M context) - -* 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) - -* 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) +Releases from 2.0.0 on are listed in [GitHub Releases](https://github.com/apimatic/apimatic-cli/releases). This file ends at 1.5.0. # [1.5.0](https://github.com/apimatic/apimatic-cli/compare/v1.4.0...v1.5.0) (2026-09-14) diff --git a/package.json b/package.json index fbc3e1b5c..f6e56df41 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@apimatic/cli", "description": "The official CLI for APIMatic.", - "version": "2.0.0-beta.2", + "version": "0.0.0-semantically-released", "author": "APIMatic", "bin": { "apimatic": "./bin/run.js" From 45b9cb12cc10ad9204eb255c3df67fad668346d4 Mon Sep 17 00:00:00 2001 From: Muhammad Saeed Date: Tue, 27 Oct 2026 15:49:07 +0500 Subject: [PATCH 2/7] feat(portal): show each merged API's own SDK sample when the platform files samples by spec (#448) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## The problem When two merged specs declare the same method and path, such as `GET /status` on two services, the platform's code samples hold one sample for the pair, keyed by path and method. The portal cannot tell whose it is, so since #435 it leaves the SDK sample off such an operation and warns. The platform is adding a grouping to its samples file. When the operations come from two or more spec files, `code-samples/.json` gains `specs`: each spec's samples, keyed by its main file relative to `src/spec/`. `paths` stays as it is, so released CLIs are unaffected. Tracked in apimatic-io issue 2298. ## What changes - **Samples filed by spec are placed by spec.** When a samples file has `specs`, each spec takes the samples filed under its own file, and only those. The file is matched exactly: `orders/openapi.yaml` for `src/spec/orders/openapi.yaml`. An operation two specs declare then shows each spec's own call. A spec with nothing filed under it shows no SDK sample, rather than calls from `paths` that may be another API's. - **A file without `specs` is read as before.** Every spec takes `paths`, less the operations two specs declare. Every samples file the platform sends today is of this kind. - **The shared-operation warning now depends on the samples file.** It moves from before the server run to after it, and appears only for a file without `specs`. - **Samples no operation takes are checked within each spec** when they are filed by spec, and the warning names the spec. - **Samples filed under a file no spec is read from are named too,** as samples no operation takes. That happens when the platform builds a merged folder from another file than the OpenAPI document the portal reads, such as a Postman collection that sorts first (apimatic-io issue 2307). - **`code-samples.json` changes shape.** The CLI writes this file for the template, and it becomes `{ paths, specs }`, with `specs` keyed by section slug. Each section reads `paths` plus its own entry. A test holds the CLI's and the template's declarations of the file to one shape, as it does for `portal.config.json`. - **Tidy-ups:** - the samples warnings live together in `src/prompts/portal/code-samples.ts`; - `PortalSource.sharedEndpoints` goes, since the placed samples work out the shared operations. ## What it prints Without `specs`, the warning reads as before but prints after the samples arrive: ``` β–² More than one API definition declares this operation, so the portal shows no SDK sample on it: β”‚ β€’ 'GET /status': 'spec/alpha/alpha.json' and 'spec/beta/beta.json' ``` A sample filed under a spec that declares no such operation: ``` β–² No operation in 'spec/orders/openapi.yaml' takes the code samples for POST /refunds. ``` Samples filed under a file the portal reads no API definition from: ``` β–² No operation in 'spec/inventory/collection.json' takes the code samples for GET /status, GET /stock. ``` ## Testing - `pnpm test` with `APIMATIC_E2E=1`, as CI runs it: 2054 passing, ESLint clean. - **Unit:** - placing samples with and without `specs`, and refusing a malformed `specs`; - exact matching: `./orders/openapi.yaml`, `orders\openapi.yaml`, `Orders/openapi.yaml` and the folder `orders` match nothing, and a spec in a nested folder matches; - both warnings, the file no spec is read from among them, and the template reading `paths` plus its section's entry, including a section named `constructor`. - **End to end:** - A new build of the merged fixture with its samples filed by spec shows each API's own call on both `GET /status` pages. - That build fails when the template is made to ignore `specs`. - The existing build without `specs` still shows no SDK sample on either `/status` page. - **Against production:** a real `portal generate` of the two-spec fixture. Production files have no `specs` yet, so the result matches #435's: - the warning names `GET /status` with both specs; - neither status page carries an SDK sample; - the other two pages carry their own calls. ## Not in this PR - **Nothing changes for users until the platform sends `specs`.** The backend change is tracked in apimatic-io issue 2298. - **An operation the platform folds into an identical one from an earlier spec** is filed under the earlier spec only, so the later spec's page shows no SDK sample. The fix is on the backend. - **A merged folder holding another API format beside its OpenAPI document,** such as a Postman collection that sorts first, gets its SDK built from that other file. The warning above names the file; the CLI should refuse such a folder (apimatic-io issue 2307). πŸ€– Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- portal-template/portal-config.ts | 2 +- .../src/lib/code-samples.server.ts | 29 +- .../src/lib/openapi-section.server.ts | 2 +- src/actions/portal/prepare-project.ts | 7 +- src/infrastructure/portal-project-service.ts | 9 +- src/prompts/portal/code-samples.ts | 22 +- src/prompts/portal/prepare-project.ts | 10 +- src/prompts/portal/source.ts | 15 +- src/types/portal-source-context.ts | 2 - src/types/portal/code-samples.ts | 192 +++++++-- src/types/portal/portal-source.ts | 5 - test/actions/portal/generate.test.ts | 40 ++ test/e2e/portal-build.test.ts | 54 ++- .../portal-project-service.test.ts | 56 ++- .../services/portal-artifacts-service.test.ts | 7 +- test/portal-template.test.ts | 4 + .../code-samples-placement.test.ts | 55 ++- test/prompts/portal/code-samples.test.ts | 85 ++++ test/prompts/portal/source.test.ts | 51 --- test/types/portal-source-context.test.ts | 26 +- test/types/portal/code-samples.test.ts | 368 ++++++++++++++---- 21 files changed, 781 insertions(+), 260 deletions(-) create mode 100644 test/prompts/portal/code-samples.test.ts diff --git a/portal-template/portal-config.ts b/portal-template/portal-config.ts index eaedaf55c..6c8b771d1 100644 --- a/portal-template/portal-config.ts +++ b/portal-template/portal-config.ts @@ -9,7 +9,7 @@ export interface BuildPaths { specs: Record; /** Absolute: the `spec/` every document sits in, with the files they refer to. */ specDir: string; - /** Absolute path of the code samples, keyed by path then method; null when there are none. */ + /** Absolute path of the code samples, a `CodeSamplesFile`; null when there are none. */ codeSamples: string | null; /** Absolute. Always exists, and may be empty. */ contentDir: string; diff --git a/portal-template/src/lib/code-samples.server.ts b/portal-template/src/lib/code-samples.server.ts index 04613546e..687721103 100644 --- a/portal-template/src/lib/code-samples.server.ts +++ b/portal-template/src/lib/code-samples.server.ts @@ -3,13 +3,34 @@ import type { Document } from 'fumadocs-openapi'; import { CODE_SAMPLES_EXTENSION } from './code-samples'; import { isJsonObject, type JsonObject } from './json'; -/** Each endpoint's code samples, keyed by path and then by upper-case method, as the CLI writes them. */ -export type CodeSamplesByEndpoint = Record>; +/** Each endpoint's code samples, keyed by path and then by upper-case method. */ +export type CodeSamplesByEndpoint = Record>; + +/** What the CLI writes: the samples filed under each spec by its slug, and in `paths` those every section takes. */ +export interface CodeSamplesFile { + paths: CodeSamplesByEndpoint; + specs: Record>; +} const HTTP_METHODS = new Set(['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace']); -export async function readCodeSamples(file: string | null): Promise { - return file === null ? {} : (JSON.parse(await readFile(file, 'utf8')) as CodeSamplesByEndpoint); +/** The samples of the section mounted at `slug`: those in `paths`, and those filed under its slug. */ +export async function readCodeSamples(file: string | null, slug: string): Promise { + if (file === null) return {}; + const { paths, specs } = JSON.parse(await readFile(file, 'utf8')) as CodeSamplesFile; + return Object.hasOwn(specs, slug) ? combined(paths, specs[slug]) : paths; +} + +/** Each endpoint's samples from both, `first`'s ahead of `second`'s. */ +function combined(first: CodeSamplesByEndpoint, second: CodeSamplesByEndpoint): CodeSamplesByEndpoint { + const merged: CodeSamplesByEndpoint = {}; + for (const [path, methods] of [...Object.entries(first), ...Object.entries(second)]) { + const byMethod = (merged[path] ??= {}); + for (const [method, samples] of Object.entries(methods)) { + byMethod[method] = [...(byMethod[method] ?? []), ...samples]; + } + } + return merged; } /** diff --git a/portal-template/src/lib/openapi-section.server.ts b/portal-template/src/lib/openapi-section.server.ts index 0fd5aee7a..9a070afe3 100644 --- a/portal-template/src/lib/openapi-section.server.ts +++ b/portal-template/src/lib/openapi-section.server.ts @@ -16,7 +16,7 @@ import { apiBaseDir } from './shared'; */ export async function openApiSection(slug: string, file: string, codeSamplesFile: string | null) { const load = async () => - placeCodeSamples(withoutInternalOperations(await bundleSpec(file)), await readCodeSamples(codeSamplesFile)); + placeCodeSamples(withoutInternalOperations(await bundleSpec(file)), await readCodeSamples(codeSamplesFile, slug)); const specName = path.basename(file); const baseDir = `${apiBaseDir}/${slug}`; const unsafe = new Set(); diff --git a/src/actions/portal/prepare-project.ts b/src/actions/portal/prepare-project.ts index fe380f6b5..9c773c189 100644 --- a/src/actions/portal/prepare-project.ts +++ b/src/actions/portal/prepare-project.ts @@ -72,7 +72,6 @@ export class PreparePortalProjectAction { this.prompts.contentNotices(source.value.contentNotices, sourceDirectory); this.prompts.unmergedSpecs(source.value.unmergedSpecs, sourceDirectory); this.prompts.leftOutSpecs(source.value.leftOutSpecs, sourceDirectory); - this.prompts.sharedEndpoints(source.value.sharedEndpoints, sourceDirectory); if (!(await confirm())) { return ActionResult.cancelled(); @@ -94,9 +93,9 @@ export class PreparePortalProjectAction { return ActionResult.failed(); } - this.prompts.unplacedSamples( - artifacts.value.codeSampleCatalogs.unplacedIn(source.value.specs.flatMap((spec) => spec.endpoints)) - ); + const codeSamples = artifacts.value.codeSampleCatalogs.placedOn(source.value.specs, source.value.specDirectory); + this.prompts.sharedEndpoints(codeSamples.sharedEndpoints(), sourceDirectory); + this.prompts.unplacedSamples(codeSamples.unplaced(), sourceDirectory); const missing = source.value.generatedPages.missingFrom(artifacts.value); if (missing !== null) { diff --git a/src/infrastructure/portal-project-service.ts b/src/infrastructure/portal-project-service.ts index ab2622372..87334ad8e 100644 --- a/src/infrastructure/portal-project-service.ts +++ b/src/infrastructure/portal-project-service.ts @@ -128,10 +128,7 @@ export class PortalProjectService { projectDirectory, source, await this.contentDirectory(projectDirectory, source), - await this.writeCodeSamples( - projectDirectory, - artifacts.codeSampleCatalogs.without(source.sharedEndpoints.map(({ key }) => key)) - ), + await this.writeCodeSamples(projectDirectory, source, artifacts.codeSampleCatalogs), await this.writeDownloads(projectDirectory, artifacts) ); @@ -192,13 +189,15 @@ export class PortalProjectService { // The template places the samples on the specs as it bundles them, so the specs are read where they are. private async writeCodeSamples( projectDirectory: DirectoryPath, + source: PortalSource, codeSampleCatalogs: CodeSampleCatalogs ): Promise { if (codeSampleCatalogs.isEmpty()) { return null; } const file = new FilePath(projectDirectory, new FileName('code-samples.json')); - await this.fileService.writeContents(file, JSON.stringify(codeSampleCatalogs.toJson())); + const placed = codeSampleCatalogs.placedOn(source.specs, source.specDirectory); + await this.fileService.writeContents(file, JSON.stringify(placed.toJson())); return file; } diff --git a/src/prompts/portal/code-samples.ts b/src/prompts/portal/code-samples.ts index 73d727489..dca226e95 100644 --- a/src/prompts/portal/code-samples.ts +++ b/src/prompts/portal/code-samples.ts @@ -1,9 +1,25 @@ import { log } from '@clack/prompts'; +import { DirectoryPath } from '../../types/file/directoryPath.js'; +import { SharedEndpoint, UnplacedSamples } from '../../types/portal/code-samples.js'; import { format as f } from '../format.js'; +import { sharedLines } from './source.js'; -export function reportUnplacedSamples(endpoints: string[]): void { - if (endpoints.length === 0) { +export function reportSharedEndpoints(shared: SharedEndpoint[], sourceDirectory: DirectoryPath): void { + if (shared.length === 0) { return; } - log.warn(`No operation in ${f.var('spec')} takes the code samples for ${endpoints.join(', ')}.`); + const one = shared.length === 1; + log.warn( + `More than one API definition declares ${one ? 'this operation' : 'these operations'}, so the portal shows no ` + + `SDK sample on ${one ? 'it' : 'them'}:` + ); + log.message(sharedLines(shared, sourceDirectory)); +} + +export function reportUnplacedSamples(unplaced: UnplacedSamples[], sourceDirectory: DirectoryPath): void { + for (const { where, endpoints } of unplaced) { + log.warn( + `No operation in ${f.var(where.relativeTo(sourceDirectory))} takes the code samples for ${endpoints.join(', ')}.` + ); + } } diff --git a/src/prompts/portal/prepare-project.ts b/src/prompts/portal/prepare-project.ts index a9b65963e..11754550f 100644 --- a/src/prompts/portal/prepare-project.ts +++ b/src/prompts/portal/prepare-project.ts @@ -5,19 +5,19 @@ import { PortalAuthorizationFailure } from '../../infrastructure/services/portal import { DirectoryPath } from '../../types/file/directoryPath.js'; import { FileName } from '../../types/file/fileName.js'; import { FilePath } from '../../types/file/filePath.js'; +import { SharedEndpoint, UnplacedSamples } from '../../types/portal/code-samples.js'; import { ContentNotices } from '../../types/portal/content-notices.js'; import { MissingArtifacts } from '../../types/portal/generated-pages.js'; import { PortalArtifacts } from '../../types/portal/portal-artifacts.js'; -import { PortalSourceProblem, SharedEndpoint } from '../../types/portal/portal-source.js'; +import { PortalSourceProblem } from '../../types/portal/portal-source.js'; import { format as f } from '../format.js'; import { describeMissingArtifacts, generateArtifacts } from './artifacts.js'; import { reportAuthorizationFailure } from './authorization.js'; -import { reportUnplacedSamples } from './code-samples.js'; +import { reportSharedEndpoints, reportUnplacedSamples } from './code-samples.js'; import { reportContentNotices, reportLeftOutSpecs, reportShadowedFiles, - reportSharedEndpoints, reportSourceProblem, reportUnmergedSpecs } from './source.js'; @@ -75,8 +75,8 @@ export class PreparePortalProjectPrompts { reportSharedEndpoints(shared, sourceDirectory); } - public unplacedSamples(endpoints: string[]) { - reportUnplacedSamples(endpoints); + public unplacedSamples(unplaced: UnplacedSamples[], sourceDirectory: DirectoryPath) { + reportUnplacedSamples(unplaced, sourceDirectory); } // A run delivers everything or nothing, so a gap is the server's, and trying again is the fix. diff --git a/src/prompts/portal/source.ts b/src/prompts/portal/source.ts index b0136e51a..5af9d2e46 100644 --- a/src/prompts/portal/source.ts +++ b/src/prompts/portal/source.ts @@ -11,7 +11,6 @@ import { PortalSourceProblem, ReservedAddressPage, SharedAddress, - SharedEndpoint, SpecConversion, UnreadableSpecReason } from '../../types/portal/portal-source.js'; @@ -57,7 +56,7 @@ const relative = (file: FilePath, sourceDirectory: DirectoryPath): string => f.v const listed = (files: FilePath[], sourceDirectory: DirectoryPath): string => listedInProse(files.map((file) => relative(file, sourceDirectory))); -const sharedLines = (claims: Shared[], sourceDirectory: DirectoryPath): string => +export const sharedLines = (claims: Shared[], sourceDirectory: DirectoryPath): string => claims.map(({ key, files }) => ` β€’ ${f.var(String(key))}: ${listed(files, sourceDirectory)}`).join('\n'); const spelt = (found: FilePath, sourceDirectory: DirectoryPath): string => @@ -417,18 +416,6 @@ export function reportLeftOutSpecs(files: FilePath[], sourceDirectory: Directory ); } -export function reportSharedEndpoints(shared: SharedEndpoint[], sourceDirectory: DirectoryPath): void { - if (shared.length === 0) { - return; - } - const one = shared.length === 1; - log.warn( - `More than one API definition declares ${one ? 'this operation' : 'these operations'}, so the portal shows no ` + - `SDK sample on ${one ? 'it' : 'them'}:` - ); - log.message(sharedLines(shared, sourceDirectory)); -} - export function reportIgnoredNavigationFiles(files: FilePath[], sourceDirectory: DirectoryPath): void { if (files.length === 0) { return; diff --git a/src/types/portal-source-context.ts b/src/types/portal-source-context.ts index 8230a3cea..f81e4e271 100644 --- a/src/types/portal-source-context.ts +++ b/src/types/portal-source-context.ts @@ -38,7 +38,6 @@ import { SpecConversion, UnreadableSpecReason } from './portal/portal-source.js'; -import { shared } from './portal/shared.js'; import { ReferencedFile, SpecBundle } from './portal/spec-bundle.js'; import { SpecMetadata } from './portal/spec-metadata.js'; import { SpecContext } from './spec-context.js'; @@ -172,7 +171,6 @@ export class PortalSourceContext { specDirectory: this.specDirectory, unmergedSpecs, leftOutSpecs, - sharedEndpoints: shared(specs.flatMap(({ file, endpoints }) => endpoints.map((key) => ({ key, file })))), contentDirectory, staticDirectory, shadowedFiles: staticDirectory === null ? [] : await this.shadowedFiles(staticDirectory), diff --git a/src/types/portal/code-samples.ts b/src/types/portal/code-samples.ts index cccef9c8a..4e4fbebc7 100644 --- a/src/types/portal/code-samples.ts +++ b/src/types/portal/code-samples.ts @@ -1,6 +1,10 @@ +import { DirectoryPath } from '../file/directoryPath.js'; +import { FilePath } from '../file/filePath.js'; import { Language, languageLabel } from '../sdk/generate.js'; import { isJsonObject } from '../../utils/json-utils.js'; import { Endpoint } from './endpoint.js'; +import { PortalSpec } from './portal-source.js'; +import { Shared, shared } from './shared.js'; type Sources = Record; @@ -10,49 +14,62 @@ export interface CodeSample { sources: Sources; } -/** Each endpoint's samples, keyed by path and then by upper-case method: what the portal template reads. */ +/** Each endpoint's samples, keyed by path and then by upper-case method. */ export type CodeSamplesByEndpoint = Record>; -interface CatalogEntry { +/** `code-samples.json`, which the template reads as `CodeSamplesFile`; a test holds the two to one shape. */ +export interface CodeSamplesFile { + paths: CodeSamplesByEndpoint; + specs: Record; +} + +export interface EndpointSample { endpoint: Endpoint; sample: CodeSample; } +/** An operation more than one spec declares, which one code sample per method and path cannot serve. */ +export type SharedEndpoint = Shared; + +/** Samples no operation in `where` takes: the file they are filed under, read as a spec or not, else `spec/`. */ +export interface UnplacedSamples { + where: FilePath | DirectoryPath; + endpoints: Endpoint[]; +} + +/** One language's samples, as one `/portal-artifacts` run delivers them. */ export class CodeSampleCatalog { - private constructor(private readonly samplesByEndpoint: Map) {} + private constructor( + private readonly paths: EndpointSample[], + /** By each spec's main file from `spec/`, as the platform keys it; empty when the operations come from one. */ + private readonly specs: ReadonlyMap + ) {} public static fromJson(language: Language, json: unknown): CodeSampleCatalog | undefined { - if (!isJsonObject(json) || !isJsonObject(json.paths)) { + if (!isJsonObject(json)) { return undefined; } + const paths = samplesIn(language, json.paths); + const specs = json.specs === undefined ? new Map() : specsIn(language, json.specs); + return paths && specs ? new CodeSampleCatalog(paths, specs) : undefined; + } - const samplesByEndpoint = new Map(); - for (const [path, methods] of Object.entries(json.paths)) { - if (!isJsonObject(methods)) { - return undefined; - } - for (const [method, sources] of Object.entries(methods)) { - if (!isSources(sources)) { - return undefined; - } - const endpoint = new Endpoint(method, path); - samplesByEndpoint.set(`${endpoint}`, { endpoint, sample: toCodeSample(language, sources) }); - } - } - return new CodeSampleCatalog(samplesByEndpoint); + public isFiledBySpec(): boolean { + return this.specs.size > 0; } - public sampleFor(endpoint: Endpoint): CodeSample | undefined { - return this.samplesByEndpoint.get(`${endpoint}`)?.sample; + // Filed by spec, every operation in `paths` is in a spec's samples too. + public unfiledSamples(): EndpointSample[] { + return this.isFiledBySpec() ? [] : this.paths; } - public endpoints(): Endpoint[] { - return [...this.samplesByEndpoint.values()].map(({ endpoint }) => endpoint); + public samplesOf(specFile: string): EndpointSample[] { + return this.specs.get(specFile) ?? []; } - public without(endpoints: Endpoint[]): CodeSampleCatalog { - const left = new Set(endpoints.map(String)); - return new CodeSampleCatalog(new Map([...this.samplesByEndpoint].filter(([key]) => !left.has(key)))); + /** The files the samples are filed under. */ + public specFiles(): string[] { + return [...this.specs.keys()]; } } @@ -63,30 +80,127 @@ export class CodeSampleCatalogs { return this.catalogs.length === 0; } - public samplesFor(endpoint: Endpoint): CodeSample[] { - return this.catalogs.flatMap((catalog) => catalog.sampleFor(endpoint) ?? []); + public placedOn(specs: PortalSpec[], specDirectory: DirectoryPath): PlacedCodeSamples { + return new PlacedCodeSamples(this.catalogs, specs, specDirectory); } +} - public without(endpoints: Endpoint[]): CodeSampleCatalogs { - return new CodeSampleCatalogs(this.catalogs.map((catalog) => catalog.without(endpoints))); +/** One run's samples on the specs: filed ones on their spec alone, others on every spec but for shared endpoints. */ +export class PlacedCodeSamples { + private readonly shared: SharedEndpoint[]; + + constructor( + private readonly catalogs: CodeSampleCatalog[], + private readonly specs: PortalSpec[], + private readonly specDirectory: DirectoryPath + ) { + this.shared = shared(specs.flatMap(({ file, endpoints }) => endpoints.map((key) => ({ key, file })))); + } + + /** The operations shown with no SDK sample because more than one spec declares them. */ + public sharedEndpoints(): SharedEndpoint[] { + return this.catalogs.some((catalog) => !catalog.isFiledBySpec()) ? this.shared : []; + } + + public unplaced(): UnplacedSamples[] { + const declared = this.specs.flatMap(({ endpoints }) => endpoints); + return [ + ...this.specs.map((spec) => ({ where: spec.file, endpoints: undeclared(this.ownSamples(spec), spec.endpoints) })), + ...this.unreadSpecFiles().map((specFile) => ({ + where: FilePath.resolve(this.specDirectory, specFile), + endpoints: endpointsOf(this.samplesOf(specFile)) + })), + { where: this.specDirectory, endpoints: undeclared(this.unfiledSamples(), declared) } + ].filter(({ endpoints }) => endpoints.length > 0); } - public unplacedIn(documented: Endpoint[]): string[] { - const names = new Set(documented.map(String)); - return [...new Set(this.endpoints().map(String))].filter((endpoint) => !names.has(endpoint)); + public toJson(): CodeSamplesFile { + const sharedKeys = new Set(this.shared.map(({ key }) => String(key))); + return { + paths: byEndpoint(this.unfiledSamples().filter(({ endpoint }) => !sharedKeys.has(String(endpoint)))), + specs: Object.fromEntries( + this.specs.flatMap((spec): [string, CodeSamplesByEndpoint][] => { + const own = this.ownSamples(spec); + return own.length === 0 ? [] : [[spec.slug, byEndpoint(own)]]; + }) + ) + }; } - public toJson(): CodeSamplesByEndpoint { - const json: CodeSamplesByEndpoint = {}; - for (const endpoint of this.endpoints()) { - json[endpoint.path] = { ...json[endpoint.path], [endpoint.method]: this.samplesFor(endpoint) }; + private ownSamples({ file }: PortalSpec): EndpointSample[] { + return this.samplesOf(file.relativeTo(this.specDirectory)); + } + + private samplesOf(specFile: string): EndpointSample[] { + return this.catalogs.flatMap((catalog) => catalog.samplesOf(specFile)); + } + + /** The files samples are filed under that no spec is read from: the platform built an API from another file. */ + private unreadSpecFiles(): string[] { + const read = new Set(this.specs.map(({ file }) => file.relativeTo(this.specDirectory))); + return [...new Set(this.catalogs.flatMap((catalog) => catalog.specFiles()))].filter((file) => !read.has(file)); + } + + private unfiledSamples(): EndpointSample[] { + return this.catalogs.flatMap((catalog) => catalog.unfiledSamples()); + } +} + +/** The endpoints of `samples`, each once. */ +function endpointsOf(samples: EndpointSample[]): Endpoint[] { + return [...new Map(samples.map(({ endpoint }) => [String(endpoint), endpoint])).values()]; +} + +/** The endpoints of `samples` that `declared` leaves out, each once. */ +function undeclared(samples: EndpointSample[], declared: Endpoint[]): Endpoint[] { + const names = new Set(declared.map(String)); + return endpointsOf(samples).filter((endpoint) => !names.has(String(endpoint))); +} + +/** Languages in the order given, each endpoint's samples together. */ +function byEndpoint(samples: EndpointSample[]): CodeSamplesByEndpoint { + const json: CodeSamplesByEndpoint = {}; + for (const { endpoint, sample } of samples) { + const methods = json[endpoint.path] ?? {}; + json[endpoint.path] = { ...methods, [endpoint.method]: [...(methods[endpoint.method] ?? []), sample] }; + } + return json; +} + +function specsIn(language: Language, specs: unknown): Map | undefined { + if (!isJsonObject(specs)) { + return undefined; + } + const samplesBySpec = new Map(); + for (const [specFile, spec] of Object.entries(specs)) { + const samples = isJsonObject(spec) ? samplesIn(language, spec.paths) : undefined; + if (samples === undefined) { + return undefined; } - return json; + samplesBySpec.set(specFile, samples); } + return samplesBySpec; +} - private endpoints(): Endpoint[] { - return this.catalogs.flatMap((catalog) => catalog.endpoints()); +/** One sample per endpoint, whatever the case its method is written in. */ +function samplesIn(language: Language, paths: unknown): EndpointSample[] | undefined { + if (!isJsonObject(paths)) { + return undefined; + } + const samples = new Map(); + for (const [path, methods] of Object.entries(paths)) { + if (!isJsonObject(methods)) { + return undefined; + } + for (const [method, sources] of Object.entries(methods)) { + if (!isSources(sources)) { + return undefined; + } + const endpoint = new Endpoint(method, path); + samples.set(`${endpoint}`, { endpoint, sample: toCodeSample(language, sources) }); + } } + return [...samples.values()]; } function toCodeSample(language: Language, sources: Sources): CodeSample { diff --git a/src/types/portal/portal-source.ts b/src/types/portal/portal-source.ts index 064b524f8..d96f48377 100644 --- a/src/types/portal/portal-source.ts +++ b/src/types/portal/portal-source.ts @@ -18,9 +18,6 @@ export interface PortalSpec { endpoints: Endpoint[]; } -/** An operation more than one specification declares, which one code sample per method and path cannot serve. */ -export type SharedEndpoint = Shared; - /** A part of the source directory that `portal serve` watches, and checks on each save. */ export type WatchedSource = 'config' | 'content' | 'spec'; @@ -44,8 +41,6 @@ export interface PortalSource extends PortalSettings { unmergedSpecs: FilePath[]; /** API definitions at the top of `spec/` while its folders are merged, which the portal and the SDKs leave out. */ leftOutSpecs: FilePath[]; - /** Shown with no SDK sample, since the samples hold one per method and path. */ - sharedEndpoints: SharedEndpoint[]; contentDirectory: DirectoryPath | null; staticDirectory: DirectoryPath | null; shadowedFiles: FileName[]; diff --git a/test/actions/portal/generate.test.ts b/test/actions/portal/generate.test.ts index c1e5f15d3..c5f30bf22 100644 --- a/test/actions/portal/generate.test.ts +++ b/test/actions/portal/generate.test.ts @@ -326,6 +326,46 @@ describe('GenerateAction', () => { expect(shared.prompts.sharedEndpoints.firstCall.args[0]).to.deep.equal([]); }); + describe('an operation two merged specs declare', () => { + const MERGED_FIXTURE = new DirectoryPath(process.cwd()).join('test/resources/portal-inputs/merged'); + const STATUS = { GET: { Example: 'const result = await client.health.getOrdersStatus();' } }; + + const deliverSamples = (catalog: object) => + shared.artifacts.resolves( + ok( + completeArtifacts(undefined, { + codeSampleCatalogs: new CodeSampleCatalogs([CodeSampleCatalog.fromJson(Language.TYPESCRIPT, catalog)!]) + }) + ) + ); + + it('is named with the specs declaring it once the samples arrive, when they are not filed by spec', async () => { + deliverSamples({ paths: { '/status': STATUS } }); + + const result = await execute(MERGED_FIXTURE); + + expect(result.isSuccess()).to.be.true; + const [endpoints] = shared.prompts.sharedEndpoints.firstCall.args; + const named = endpoints.map(({ key, files }) => [`${key}`, files.map((file) => file.relativeTo(MERGED_FIXTURE))]); + expect(named).to.deep.equal([ + ['GET /status', ['src/spec/orders/openapi.yaml', 'src/spec/inventory/openapi.json']] + ]); + expect(shared.prompts.sharedEndpoints.calledAfter(shared.artifacts)).to.be.true; + }); + + it('is not named when the samples are filed by spec', async () => { + deliverSamples({ + paths: { '/status': STATUS }, + specs: { 'orders/openapi.yaml': { paths: { '/status': STATUS } } } + }); + + const result = await execute(MERGED_FIXTURE); + + expect(result.isSuccess()).to.be.true; + expect(shared.prompts.sharedEndpoints.firstCall.args[0]).to.deep.equal([]); + }); + }); + // The fixture's root nav.json makes a tab of `guides`, and no two of its tabs share a name. it('reports the content notices, with no tab name shared in the fixture', async () => { const result = await execute(); diff --git a/test/e2e/portal-build.test.ts b/test/e2e/portal-build.test.ts index 9e6fc12a6..cb2491bd1 100644 --- a/test/e2e/portal-build.test.ts +++ b/test/e2e/portal-build.test.ts @@ -927,6 +927,14 @@ const stylesheetOf = (output: DirectoryPath) => { }); }); +/** A TypeScript sample of `call`, as the platform delivers one. */ +const sample = (call: string) => ({ Example: `const result = await ${call};` }); + +const LIST_ORDERS = 'api/orders/orders/listOrders'; +const LIST_STOCK = 'api/inventory/stock/listStock'; +const ORDERS_STATUS = 'api/orders/health/getOrdersStatus'; +const INVENTORY_STATUS = 'api/inventory/health/getInventoryStatus'; + /** * The layout the platform merges several APIs into one SDK from: a folder to each API, named in * `APIMATIC-META.json`. Both APIs declare `GET /status`, and the orders API keeps a schema in a @@ -935,7 +943,6 @@ const stylesheetOf = (output: DirectoryPath) => { (enabled ? describe : describe.skip)('portal build, merged spec folders (end to end)', function () { this.timeout(10 * 60 * 1000); - const sample = (call: string) => ({ Example: `const result = await ${call};` }); const portal = portalBuilt('merged', { codeSampleCatalogs: new CodeSampleCatalogs([ CodeSampleCatalog.fromJson(Language.TYPESCRIPT, { @@ -949,9 +956,7 @@ const stylesheetOf = (output: DirectoryPath) => { }); const { read, exists } = portal; - const LIST_ORDERS = 'api/orders/orders/listOrders'; - const LIST_STOCK = 'api/inventory/stock/listStock'; - const STATUS_PAGES = ['api/orders/health/getOrdersStatus', 'api/inventory/health/getInventoryStatus']; + const STATUS_PAGES = [ORDERS_STATUS, INVENTORY_STATUS]; it('writes a section per merged folder, named after it, with a page per operation', () => { for (const page of [LIST_ORDERS, LIST_STOCK, ...STATUS_PAGES]) { @@ -971,7 +976,7 @@ const stylesheetOf = (output: DirectoryPath) => { expect(stock).to.contain('https://inventory.store.test'); }); - // The samples hold one per method and path: the inventory page would show the orders API's call. + // Not filed by spec, the samples hold one per method and path: the inventory page would show the orders API's call. it('shows no SDK sample on an operation both APIs declare', () => { for (const page of STATUS_PAGES) { expect(read(`${page}/index.html`), page).to.not.contain('x-apimatic-codeSamples'); @@ -983,3 +988,42 @@ const stylesheetOf = (output: DirectoryPath) => { expect(read(`${LIST_ORDERS}.md`)).to.contain('grandTotalCents'); }); }); + +/** + * The same folders with the samples filed by spec, as the platform files them for two specs: each + * under its main file, while `paths` keeps the first call it reached on `GET /status`. + */ +(enabled ? describe : describe.skip)('portal build, samples filed by spec (end to end)', function () { + this.timeout(10 * 60 * 1000); + + const ordersStatus = 'client.health.getOrdersStatus()'; + const inventoryStatus = 'client.health.getInventoryStatus()'; + const listOrders = { GET: sample('client.orders.listOrders()') }; + const listStock = { GET: sample('client.stock.listStock()') }; + const { read } = portalBuilt('merged', { + codeSampleCatalogs: new CodeSampleCatalogs([ + CodeSampleCatalog.fromJson(Language.TYPESCRIPT, { + paths: { '/orders': listOrders, '/stock': listStock, '/status': { GET: sample(ordersStatus) } }, + specs: { + 'orders/openapi.yaml': { paths: { '/orders': listOrders, '/status': { GET: sample(ordersStatus) } } }, + 'inventory/openapi.json': { paths: { '/stock': listStock, '/status': { GET: sample(inventoryStatus) } } } + } + }) as CodeSampleCatalog + ]) + }); + + it("shows each API's own SDK call on the operation both declare", () => { + const orders = read(`${ORDERS_STATUS}/index.html`); + const inventory = read(`${INVENTORY_STATUS}/index.html`); + + expect(orders).to.contain(ordersStatus); + expect(orders).to.not.contain(inventoryStatus); + expect(inventory).to.contain(inventoryStatus); + expect(inventory).to.not.contain(ordersStatus); + }); + + it("shows each API's own SDK call on an operation only it declares", () => { + expect(read(`${LIST_ORDERS}/index.html`)).to.contain('client.orders.listOrders()'); + expect(read(`${LIST_STOCK}/index.html`)).to.contain('client.stock.listStock()'); + }); +}); diff --git a/test/infrastructure/portal-project-service.test.ts b/test/infrastructure/portal-project-service.test.ts index f6ace380a..5724fe4cd 100644 --- a/test/infrastructure/portal-project-service.test.ts +++ b/test/infrastructure/portal-project-service.test.ts @@ -64,7 +64,6 @@ describe('PortalProjectService', () => { specDirectory: new DirectoryPath(root).join('spec'), unmergedSpecs: [], leftOutSpecs: [], - sharedEndpoints: [], contentDirectory: null, staticDirectory: null, shadowedFiles: [], @@ -308,27 +307,62 @@ describe('PortalProjectService', () => { undefined ); + const LIST_PETS = { lang: 'typescript', label: 'TypeScript', sources: { Example: 'await client.pets.list();' } }; + + /** Two specs in folders of their own, both declaring `GET /pets`. */ + const sharingSource = () => { + const spec = new DirectoryPath(root).join('spec'); + return sourceFor({ + specs: ['alpha', 'beta'].map((folder) => ({ + slug: folder, + file: new FilePath(spec.join(folder), new FileName('openapi.json')), + endpoints: [new Endpoint('get', '/pets')] + })) + }); + }; + + const writtenSamples = () => JSON.parse(fs.readFileSync(readConfig().codeSamples, 'utf8')); + it('writes the samples beside the configuration, keyed by path and method, and names them in it', async () => { (await service.prepare(project, sourceFor(), withCatalogs))._unsafeUnwrap(); const config = readConfig(); expect(config.codeSamples).to.equal(path.join(project.toString(), 'code-samples.json').split(path.sep).join('/')); - expect(JSON.parse(fs.readFileSync(config.codeSamples, 'utf8'))).to.deep.equal({ - '/pets': { - GET: [{ lang: 'typescript', label: 'TypeScript', sources: { Example: 'await client.pets.list();' } }] - } - }); + expect(writtenSamples()).to.deep.equal({ paths: { '/pets': { GET: [LIST_PETS] } }, specs: {} }); }); // The samples hold one per method and path, which would be another API's call on all but one of the pages. it('leaves out the samples of an operation more than one spec declares', async () => { - const spec = new DirectoryPath(root).join('spec'); - const files = ['alpha', 'beta'].map((folder) => new FilePath(spec.join(folder), new FileName('openapi.json'))); - const source = sourceFor({ sharedEndpoints: [{ key: new Endpoint('get', '/pets'), files }] }); + (await service.prepare(project, sharingSource(), withCatalogs))._unsafeUnwrap(); - (await service.prepare(project, source, withCatalogs))._unsafeUnwrap(); + expect(writtenSamples()).to.deep.equal({ paths: {}, specs: {} }); + }); + + it("writes each spec's own samples under its slug when the run files them by spec", async () => { + const filed = new PortalArtifacts( + new CodeSampleCatalogs([ + CodeSampleCatalog.fromJson(Language.TYPESCRIPT, { + paths: { '/pets': { GET: { Example: 'await client.pets.list();' } } }, + specs: { + 'alpha/openapi.json': { paths: { '/pets': { GET: { Example: 'await client.pets.list();' } } } }, + 'beta/openapi.json': { paths: { '/pets': { GET: { Example: 'await client.stock.list();' } } } } + } + }) as CodeSampleCatalog + ]), + new Map(), + new Map(), + undefined + ); + + (await service.prepare(project, sharingSource(), filed))._unsafeUnwrap(); - expect(JSON.parse(fs.readFileSync(readConfig().codeSamples, 'utf8'))).to.deep.equal({}); + expect(writtenSamples()).to.deep.equal({ + paths: {}, + specs: { + alpha: { '/pets': { GET: [LIST_PETS] } }, + beta: { '/pets': { GET: [{ ...LIST_PETS, sources: { Example: 'await client.stock.list();' } }] } } + } + }); }); it('points each spec at its own file, samples or not', async () => { diff --git a/test/infrastructure/services/portal-artifacts-service.test.ts b/test/infrastructure/services/portal-artifacts-service.test.ts index e7d0574b5..185db4c85 100644 --- a/test/infrastructure/services/portal-artifacts-service.test.ts +++ b/test/infrastructure/services/portal-artifacts-service.test.ts @@ -12,7 +12,6 @@ import { envInfo } from '../../../src/infrastructure/env-info'; import { DirectoryPath } from '../../../src/types/file/directoryPath'; import { FileName } from '../../../src/types/file/fileName'; import { FilePath } from '../../../src/types/file/filePath'; -import { Endpoint } from '../../../src/types/portal/endpoint'; const GENERATION_ID = '11111111-2222-3333-4444-555555555555'; const AUTH_KEY = 'test-auth-key'; @@ -140,8 +139,8 @@ describe('PortalArtifactsService', () => { it('reads a catalog per language into the samples the build merges', async () => { const artifacts = (await generate())._unsafeUnwrap(); - const samples = artifacts.codeSampleCatalogs.samplesFor(new Endpoint('GET', '/payments')); - expect(samples.map((sample) => sample.lang)).to.deep.equal(['csharp']); + const { paths } = artifacts.codeSampleCatalogs.placedOn([], source.join('spec')).toJson(); + expect(paths['/payments'].GET.map((sample) => sample.lang)).to.deep.equal(['csharp']); }); // Keyed by the delivered name rather than parsed, so placement is a copy under the same name. @@ -174,7 +173,7 @@ describe('PortalArtifactsService', () => { it('succeeds with no samples, no SDKs and no plugin', async () => { const artifacts = (await generate())._unsafeUnwrap(); - expect(artifacts.codeSampleCatalogs.samplesFor(new Endpoint('GET', '/payments'))).to.be.empty; + expect(artifacts.codeSampleCatalogs.isEmpty()).to.be.true; expect(artifacts.sdks.size).to.equal(0); expect(artifacts.sdkDocs.size).to.equal(0); expect(artifacts.plugin).to.be.undefined; diff --git a/test/portal-template.test.ts b/test/portal-template.test.ts index 83d5799e2..5c5c4f321 100644 --- a/test/portal-template.test.ts +++ b/test/portal-template.test.ts @@ -10,10 +10,12 @@ import { PortalBuildPaths, TEMPLATE_DEPENDENCIES } from '../src/infrastructure/portal-project-service'; +import { CodeSample, CodeSamplesFile } from '../src/types/portal/code-samples'; import { GENERATED_DIRECTORY_NAME, PAGE_TEMPLATES } from '../src/types/portal/generated-pages'; import { GENERATED_INCLUDES_DIRECTORY_NAME } from '../src/types/portal/page-fragments'; import { PortalIdentity } from '../src/types/portal/portal-config'; import type { BuildPaths } from '../portal-template/portal-config'; +import type { CodeSamplesFile as TemplateCodeSamplesFile } from '../portal-template/src/lib/code-samples.server'; import type { Portal } from '../portal-template/src/lib/portal-types'; /** True only when the two types are identical, every nested field and union member included. */ @@ -204,9 +206,11 @@ describe('portal template packaging', () => { it('declares exactly what the CLI writes for it', () => { const identity: Equal = true; const paths: Equal = true; + const codeSamples: Equal, CodeSamplesFile> = true; expect(identity).to.equal(true); expect(paths).to.equal(true); + expect(codeSamples).to.equal(true); }); // The CLI writes these into the prepared project; a copy in the template would be a second diff --git a/test/portal-template/code-samples-placement.test.ts b/test/portal-template/code-samples-placement.test.ts index b40f07c05..848e95d90 100644 --- a/test/portal-template/code-samples-placement.test.ts +++ b/test/portal-template/code-samples-placement.test.ts @@ -11,6 +11,7 @@ import { bundleSpec } from '../../portal-template/src/lib/openapi-bundle.server' const EXTENSION = 'x-apimatic-codeSamples'; const typescript = (id: string) => ({ lang: 'typescript', label: 'TypeScript', sources: { [id]: `ts ${id}` } }); +const csharp = (id: string) => ({ lang: 'csharp', label: 'C#', sources: { [id]: `cs ${id}` } }); const operation = (summary: string) => ({ summary, responses: { '200': { description: 'ok' } } }); @@ -70,7 +71,7 @@ describe('placeCodeSamples', () => { it('drops the samples an operation carries when the build has no samples file', async () => { const written = { paths: { '/pets': { get: { ...operation('List'), [EXTENSION]: [typescript('own')] } } } }; - expect(placed(written, await readCodeSamples(null)).paths['/pets'].get).to.not.have.property(EXTENSION); + expect(placed(written, await readCodeSamples(null, 'pets')).paths['/pets'].get).to.not.have.property(EXTENSION); }); it('gives two paths that share one operation object their own samples', () => { @@ -112,11 +113,11 @@ describe('placeCodeSamples', () => { components: { pathItems: { Owners: { get: operation('List owners') } } } }); const samplesFile = write('code-samples.json', { - '/pets': { GET: [typescript('pets')] }, - '/owners': { GET: [typescript('owners')] } + paths: { '/pets': { GET: [typescript('pets')] }, '/owners': { GET: [typescript('owners')] } }, + specs: {} }); - const samples = await readCodeSamples(samplesFile); + const samples = await readCodeSamples(samplesFile, 'api'); const server = createOpenAPI({ input: { api: async () => placeCodeSamples(await bundleSpec(file), samples) } }); @@ -127,3 +128,49 @@ describe('placeCodeSamples', () => { }); }); }); + +describe('readCodeSamples', () => { + let root: string; + + /** The samples file the CLI writes: samples filed under `orders`, and `paths` for every section. */ + const samplesFile = (paths: object) => { + const file = path.join(root, 'code-samples.json'); + fs.writeFileSync( + file, + JSON.stringify({ paths, specs: { orders: { '/status': { GET: [typescript('orders')] } } } }) + ); + return file; + }; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'code-samples-read-')); + }); + + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('gives a section paths plus the samples filed under its slug, and any other section paths alone', async () => { + const file = samplesFile({ '/health': { GET: [typescript('health')] } }); + + expect(await readCodeSamples(file, 'orders')).to.deep.equal({ + '/health': { GET: [typescript('health')] }, + '/status': { GET: [typescript('orders')] } + }); + expect(await readCodeSamples(file, 'inventory')).to.deep.equal({ '/health': { GET: [typescript('health')] } }); + }); + + it("puts a section's own samples after those in paths on one operation", async () => { + const file = samplesFile({ '/status': { GET: [csharp('status')] } }); + + expect(await readCodeSamples(file, 'orders')).to.deep.equal({ + '/status': { GET: [csharp('status'), typescript('orders')] } + }); + }); + + it("gives a section named after one of an object's own members the samples in paths alone", async () => { + const file = samplesFile({ '/status': { GET: [typescript('shared')] } }); + + expect(await readCodeSamples(file, 'constructor')).to.deep.equal({ '/status': { GET: [typescript('shared')] } }); + }); +}); diff --git a/test/prompts/portal/code-samples.test.ts b/test/prompts/portal/code-samples.test.ts new file mode 100644 index 000000000..87613946b --- /dev/null +++ b/test/prompts/portal/code-samples.test.ts @@ -0,0 +1,85 @@ +import { stripVTControlCharacters } from 'node:util'; +import { expect } from 'chai'; +import sinon from 'sinon'; +import { log } from '@clack/prompts'; +import { reportSharedEndpoints, reportUnplacedSamples } from '../../../src/prompts/portal/code-samples.js'; +import { DirectoryPath } from '../../../src/types/file/directoryPath.js'; +import { FileName } from '../../../src/types/file/fileName.js'; +import { FilePath } from '../../../src/types/file/filePath.js'; +import { Endpoint } from '../../../src/types/portal/endpoint.js'; + +const source = new DirectoryPath('project').join('src'); +const files = ['alpha', 'beta'].map( + (folder) => new FilePath(source.join('spec').join(folder), new FileName('openapi.json')) +); + +describe('the code samples prompts', () => { + let lines: string[]; + + beforeEach(() => { + lines = []; + const record = (text?: string | string[]) => { + lines.push(stripVTControlCharacters(String(text))); + }; + sinon.stub(log, 'warn').callsFake(record); + sinon.stub(log, 'message').callsFake(record); + }); + + afterEach(() => { + sinon.restore(); + }); + + describe('reportSharedEndpoints', () => { + it('says nothing when no two specs declare the same operation', () => { + reportSharedEndpoints([], source); + + expect(lines).to.deep.equal([]); + }); + + it('names each operation and the specs declaring it, and why it shows no SDK sample', () => { + reportSharedEndpoints( + [ + { key: new Endpoint('get', '/status'), files }, + { key: new Endpoint('post', '/status'), files } + ], + source + ); + + expect(lines.join('\n').split('\n')).to.deep.equal([ + 'More than one API definition declares these operations, so the portal shows no SDK sample on them:', + " β€’ 'GET /status': 'spec/alpha/openapi.json' and 'spec/beta/openapi.json'", + " β€’ 'POST /status': 'spec/alpha/openapi.json' and 'spec/beta/openapi.json'" + ]); + }); + + it('speaks of one operation when there is one', () => { + reportSharedEndpoints([{ key: new Endpoint('get', '/status'), files }], source); + + expect(lines[0]).to.contain('declares this operation,'); + expect(lines[0]).to.contain('no SDK sample on it:'); + }); + }); + + describe('reportUnplacedSamples', () => { + it('says nothing when every sample has an operation', () => { + reportUnplacedSamples([], source); + + expect(lines).to.deep.equal([]); + }); + + it('names the spec of the samples filed by spec, and spec/ for the others', () => { + reportUnplacedSamples( + [ + { where: files[0], endpoints: [new Endpoint('get', '/refunds'), new Endpoint('post', '/refunds')] }, + { where: source.join('spec'), endpoints: [new Endpoint('get', '/owners')] } + ], + source + ); + + expect(lines).to.deep.equal([ + "No operation in 'spec/alpha/openapi.json' takes the code samples for GET /refunds, POST /refunds.", + "No operation in './spec' takes the code samples for GET /owners." + ]); + }); + }); +}); diff --git a/test/prompts/portal/source.test.ts b/test/prompts/portal/source.test.ts index 62fe5069f..dfdd1c749 100644 --- a/test/prompts/portal/source.test.ts +++ b/test/prompts/portal/source.test.ts @@ -7,13 +7,11 @@ import { reportLeftOutSpecs, reportNoTabsListed, reportShadowedFiles, - reportSharedEndpoints, reportSharedTabNames, reportSourceProblem, reportUnmergedSpecs, reportUnseenHomeTitle } from '../../../src/prompts/portal/source.js'; -import { Endpoint } from '../../../src/types/portal/endpoint.js'; import { ContentProblem, UnreadableSpecReason } from '../../../src/types/portal/portal-source.js'; import { DirectoryPath } from '../../../src/types/file/directoryPath.js'; import { FileName } from '../../../src/types/file/fileName.js'; @@ -855,52 +853,3 @@ describe('reportLeftOutSpecs', () => { ]); }); }); - -describe('reportSharedEndpoints', () => { - const source = new DirectoryPath('project').join('src'); - const spec = source.join('spec'); - const files = ['alpha', 'beta'].map((folder) => new FilePath(spec.join(folder), new FileName('openapi.json'))); - let lines: string[]; - - beforeEach(() => { - lines = []; - const record = (text?: string | string[]) => { - lines.push(stripVTControlCharacters(String(text))); - }; - sinon.stub(log, 'warn').callsFake(record); - sinon.stub(log, 'message').callsFake(record); - }); - - afterEach(() => { - sinon.restore(); - }); - - it('says nothing when no two specs declare the same operation', () => { - reportSharedEndpoints([], source); - - expect(lines).to.deep.equal([]); - }); - - it('names each operation and the specs declaring it, and why it shows no SDK sample', () => { - reportSharedEndpoints( - [ - { key: new Endpoint('get', '/status'), files }, - { key: new Endpoint('post', '/status'), files } - ], - source - ); - - expect(lines.join('\n').split('\n')).to.deep.equal([ - 'More than one API definition declares these operations, so the portal shows no SDK sample on them:', - " β€’ 'GET /status': 'spec/alpha/openapi.json' and 'spec/beta/openapi.json'", - " β€’ 'POST /status': 'spec/alpha/openapi.json' and 'spec/beta/openapi.json'" - ]); - }); - - it('speaks of one operation when there is one', () => { - reportSharedEndpoints([{ key: new Endpoint('get', '/status'), files }], source); - - expect(lines[0]).to.contain('declares this operation,'); - expect(lines[0]).to.contain('no SDK sample on it:'); - }); -}); diff --git a/test/types/portal-source-context.test.ts b/test/types/portal-source-context.test.ts index 7b55d3e59..16a694193 100644 --- a/test/types/portal-source-context.test.ts +++ b/test/types/portal-source-context.test.ts @@ -959,34 +959,12 @@ describe('PortalSourceContext', () => { expect((await resolve())._unsafeUnwrap().unmergedSpecs).to.be.empty; }); - it('names each operation more than one spec declares, with the specs that do', async () => { - write('spec/APIMATIC-META.json', JSON.stringify({ MergeConfiguration: { MergeApis: true } })); - write('spec/alpha/openapi.json', withPaths({ '/status': { get: {} }, '/alpha': { get: {} } })); - write('spec/beta/openapi.json', withPaths({ '/status': { get: {}, post: {} }, '/beta': { get: {} } })); - - const shared = (await resolve())._unsafeUnwrap().sharedEndpoints; - - expect(shared.map(({ key, files }) => [`${key}`, files.map(relative)])).to.deep.equal([ - ['GET /status', ['spec/alpha/openapi.json', 'spec/beta/openapi.json']] - ]); - }); - - it('names none when no two specs declare the same operation', async () => { - write('spec/a.json', withPaths({ '/a': { get: {} } })); - write('spec/b.json', withPaths({ '/b': { get: {} } })); - - expect((await resolve())._unsafeUnwrap().sharedEndpoints).to.be.empty; - }); - // A path item's own method beside its `$ref` declares the operation a second time, in the one spec. - it('reads an operation one spec declares twice once, and names none shared', async () => { + it('reads an operation one spec declares twice once', async () => { write('spec/paths/pets.json', JSON.stringify({ get: {} })); write('spec/api.json', withPaths({ '/pets': { $ref: './paths/pets.json', get: {} } })); - const source = (await resolve())._unsafeUnwrap(); - - expect(source.specs[0].endpoints.map(String)).to.deep.equal(['GET /pets']); - expect(source.sharedEndpoints).to.be.empty; + expect((await resolve())._unsafeUnwrap().specs[0].endpoints.map(String)).to.deep.equal(['GET /pets']); }); }); }); diff --git a/test/types/portal/code-samples.test.ts b/test/types/portal/code-samples.test.ts index 2a2729da7..7597637e0 100644 --- a/test/types/portal/code-samples.test.ts +++ b/test/types/portal/code-samples.test.ts @@ -1,16 +1,41 @@ +import path from 'path'; import { expect } from 'chai'; -import { CodeSampleCatalog, CodeSampleCatalogs } from '../../../src/types/portal/code-samples'; +import { DirectoryPath } from '../../../src/types/file/directoryPath'; +import { FilePath } from '../../../src/types/file/filePath'; +import { + CodeSampleCatalog, + CodeSampleCatalogs, + CodeSamplesFile, + UnplacedSamples +} from '../../../src/types/portal/code-samples'; import { Endpoint } from '../../../src/types/portal/endpoint'; +import { PortalSpec } from '../../../src/types/portal/portal-source'; import { Language } from '../../../src/types/sdk/generate'; -const catalog = (language: Language, paths: unknown): CodeSampleCatalog => { - const parsed = CodeSampleCatalog.fromJson(language, { paths, webhooks: {} }); - expect(parsed, `unparsable: ${JSON.stringify(paths)}`).to.not.be.undefined; +const SOURCE_DIRECTORY = new DirectoryPath(path.resolve('portal', 'src')); +const SPEC_DIRECTORY = SOURCE_DIRECTORY.join('spec'); + +const catalog = (language: Language, json: object): CodeSampleCatalog => { + const parsed = CodeSampleCatalog.fromJson(language, { webhooks: {}, ...json }); + expect(parsed, `unparsable: ${JSON.stringify(json)}`).to.not.be.undefined; return parsed as CodeSampleCatalog; }; -const samples = (codeSampleCatalogs: CodeSampleCatalogs, endpoint: Endpoint) => - codeSampleCatalogs.samplesFor(endpoint).map(({ label, sources }) => ({ label, sources })); +/** A spec at `file`, relative to `spec/`, declaring each of `endpoints` ("GET /status"). */ +const spec = (slug: string, file: string, endpoints: string[]): PortalSpec => ({ + slug, + file: FilePath.resolve(SPEC_DIRECTORY, file), + endpoints: endpoints.map((endpoint) => { + const [method, route] = endpoint.split(' '); + return new Endpoint(method, route); + }) +}); + +const placed = (catalogs: CodeSampleCatalog[], specs: PortalSpec[]): CodeSamplesFile => + new CodeSampleCatalogs(catalogs).placedOn(specs, SPEC_DIRECTORY).toJson(); + +const typescript = (call: string) => ({ lang: 'typescript', label: 'TypeScript', sources: { Example: call } }); +const csharp = (call: string) => ({ lang: 'csharp', label: 'C#', sources: { Example: call } }); describe('CodeSampleCatalog', () => { it('rejects a catalog that is not the wire shape', () => { @@ -19,98 +44,285 @@ describe('CodeSampleCatalog', () => { expect(CodeSampleCatalog.fromJson(Language.TYPESCRIPT, { paths: { '/a': { GET: { x: 1 } } } })).to.be.undefined; }); - it('looks a sample up by method whatever its case', () => { - const typescript = catalog(Language.TYPESCRIPT, { '/pets': { GET: { Example: 'list()' } } }); + it('rejects samples filed by spec that are not the wire shape', () => { + const filed = (specs: unknown) => CodeSampleCatalog.fromJson(Language.TYPESCRIPT, { paths: {}, specs }); - expect(typescript.sampleFor(new Endpoint('get', '/pets'))).to.not.be.undefined; + expect(filed([])).to.be.undefined; + expect(filed({ 'a/a.json': {} })).to.be.undefined; + expect(filed({ 'a/a.json': { paths: { '/a': { GET: { x: 1 } } } } })).to.be.undefined; }); +}); - it('labels a sample with the language name alone', () => { - const codeSampleCatalogs = new CodeSampleCatalogs([ - catalog(Language.CSHARP, { '/pets': { GET: { Example: 'List()' } } }) - ]); +describe('PlacedCodeSamples', () => { + const placedOn = (catalogs: CodeSampleCatalog[], specs: PortalSpec[]) => + new CodeSampleCatalogs(catalogs).placedOn(specs, SPEC_DIRECTORY); - expect(samples(codeSampleCatalogs, new Endpoint('GET', '/pets'))).to.deep.equal([ - { label: 'C#', sources: { Example: 'List()' } } - ]); - }); + const named = (file: FilePath) => file.relativeTo(SPEC_DIRECTORY); + /** Each place named as a warning names it, from the source directory. */ + const unplacedIn = (unplaced: UnplacedSamples[]) => + unplaced.map(({ where, endpoints }) => [where.relativeTo(SOURCE_DIRECTORY), endpoints.map(String)]); - it('keeps every example of an operation in declaration order', () => { - const codeSampleCatalogs = new CodeSampleCatalogs([ - catalog(Language.TYPESCRIPT, { '/pets': { POST: { minimal: 'a', full: 'b', basic: 'c' } } }) - ]); + describe('the operations shown with no SDK sample', () => { + const alpha = spec('alpha', 'alpha/openapi.json', ['GET /status', 'GET /alpha']); + const beta = spec('beta', 'beta/openapi.json', ['GET /status', 'POST /status', 'GET /beta']); + const unfiled = catalog(Language.TYPESCRIPT, { paths: { '/status': { GET: { Example: 'status' } } } }); - const [sample] = codeSampleCatalogs.samplesFor(new Endpoint('POST', '/pets')); - expect(Object.keys(sample.sources)).to.deep.equal(['minimal', 'full', 'basic']); - }); + it('names each operation more than one spec declares, with the specs that do', () => { + const shared = placedOn([unfiled], [alpha, beta]).sharedEndpoints(); - it('keeps an example named Example beside other examples', () => { - const codeSampleCatalogs = new CodeSampleCatalogs([ - catalog(Language.PYTHON, { '/pets': { POST: { Example: 'x', minimal: 'a', full: 'b' } } }) - ]); + expect(shared.map(({ key, files }) => [`${key}`, files.map(named)])).to.deep.equal([ + ['GET /status', ['alpha/openapi.json', 'beta/openapi.json']] + ]); + }); - expect(samples(codeSampleCatalogs, new Endpoint('POST', '/pets'))).to.deep.equal([ - { label: 'Python', sources: { Example: 'x', minimal: 'a', full: 'b' } } - ]); - }); -}); + it('names none when no two specs declare the same operation', () => { + const gamma = spec('gamma', 'gamma/openapi.json', ['GET /gamma']); -describe('CodeSampleCatalogs', () => { - it('gives one sample per language, in catalog order', () => { - const codeSampleCatalogs = new CodeSampleCatalogs([ - catalog(Language.TYPESCRIPT, { '/pets': { POST: { minimal: 'a', full: 'b' } } }), - catalog(Language.CSHARP, { '/pets': { POST: { minimal: 'c', full: 'd' } } }) - ]); - - expect(samples(codeSampleCatalogs, new Endpoint('POST', '/pets')).map(({ label }) => label)).to.deep.equal([ - 'TypeScript', - 'C#' - ]); - }); + expect(placedOn([unfiled], [alpha, gamma]).sharedEndpoints()).to.be.empty; + }); - it('has nothing for an operation a language has no sample for', () => { - const codeSampleCatalogs = new CodeSampleCatalogs([ - catalog(Language.TYPESCRIPT, { '/pets': { GET: { Example: 'a' } } }) - ]); + it('names none when the samples are filed by spec, each spec showing its own', () => { + const filed = catalog(Language.TYPESCRIPT, { + paths: { '/status': { GET: { Example: 'alpha status' } } }, + specs: { + 'alpha/openapi.json': { paths: { '/status': { GET: { Example: 'alpha status' } } } }, + 'beta/openapi.json': { paths: { '/status': { GET: { Example: 'beta status' } } } } + } + }); - expect(codeSampleCatalogs.samplesFor(new Endpoint('DELETE', '/pets'))).to.be.empty; + expect(placedOn([filed], [alpha, beta]).sharedEndpoints()).to.be.empty; + }); }); - it('names the samples no document has an operation for', () => { - const codeSampleCatalogs = new CodeSampleCatalogs([ - catalog(Language.TYPESCRIPT, { '/pets': { GET: { Example: 'a' } }, '/owners': { GET: { Example: 'b' } } }), - catalog(Language.CSHARP, { '/owners': { GET: { Example: 'c' } } }) - ]); + describe('the samples no operation takes', () => { + it('names, of the samples not filed by spec, those no spec declares, once across languages', () => { + const unplaced = placedOn( + [ + catalog(Language.TYPESCRIPT, { + paths: { '/pets': { GET: { Example: 'a' } }, '/owners': { GET: { Example: 'b' } } } + }), + catalog(Language.CSHARP, { paths: { '/owners': { GET: { Example: 'c' } } } }) + ], + [spec('pets', 'openapi.json', ['GET /pets'])] + ).unplaced(); + + expect(unplacedIn(unplaced)).to.deep.equal([['./spec', ['GET /owners']]]); + }); + + it('names, of the samples filed under a spec, those it does not declare, and none of paths', () => { + const unplaced = placedOn( + [ + catalog(Language.TYPESCRIPT, { + paths: { '/orders': { GET: { Example: 'orders' } }, '/refunds': { POST: { Example: 'refund' } } }, + specs: { + 'orders/openapi.yaml': { + paths: { '/orders': { GET: { Example: 'orders' } }, '/refunds': { POST: { Example: 'refund' } } } + } + } + }) + ], + [spec('orders', 'orders/openapi.yaml', ['GET /orders'])] + ).unplaced(); + + expect(unplacedIn(unplaced)).to.deep.equal([['spec/orders/openapi.yaml', ['POST /refunds']]]); + }); + + // A merged folder built from another file than the OpenAPI document the portal reads (apimatic-io#2307). + it('names every sample filed under a file no spec is read from, once across languages', () => { + const collection = (call: string) => ({ + paths: {}, + specs: { + 'inventory/collection.json': { + paths: { '/status': { GET: { Example: call } }, '/stock': { GET: { Example: call } } } + } + } + }); - expect(codeSampleCatalogs.unplacedIn([new Endpoint('get', '/pets')])).to.deep.equal(['GET /owners']); + const unplaced = placedOn( + [catalog(Language.TYPESCRIPT, collection('ts')), catalog(Language.CSHARP, collection('cs'))], + [spec('inventory', 'inventory/openapi.json', ['GET /status', 'GET /stock'])] + ).unplaced(); + + expect(unplacedIn(unplaced)).to.deep.equal([['spec/inventory/collection.json', ['GET /status', 'GET /stock']]]); + }); }); - it('leaves out the samples of the given endpoints, in every language, and keeps the rest', () => { - const codeSampleCatalogs = new CodeSampleCatalogs([ - catalog(Language.TYPESCRIPT, { '/pets': { GET: { Example: 'a' }, POST: { Example: 'b' } } }), - catalog(Language.CSHARP, { '/pets': { GET: { Example: 'c' } } }) - ]).without([new Endpoint('get', '/pets')]); + describe('samples not filed by spec', () => { + const calculator = spec('calculator', 'openapi.json', ['GET /pets', 'POST /pets']); + + it('keys each endpoint by path and upper-case method, languages in catalog order, labelled by name alone', () => { + const file = placed( + [ + catalog(Language.TYPESCRIPT, { paths: { '/pets': { get: { Example: 'a' }, POST: { Example: 'b' } } } }), + catalog(Language.CSHARP, { paths: { '/pets': { GET: { Example: 'c' } } } }) + ], + [calculator] + ); + + expect(file).to.deep.equal({ + paths: { '/pets': { GET: [typescript('a'), csharp('c')], POST: [typescript('b')] } }, + specs: {} + }); + }); + + it('keeps every example of an operation in declaration order, one named Example among them', () => { + const file = placed( + [catalog(Language.PYTHON, { paths: { '/pets': { POST: { minimal: 'a', Example: 'x', full: 'b' } } } })], + [calculator] + ); + + expect(Object.keys(file.paths['/pets'].POST[0].sources)).to.deep.equal(['minimal', 'Example', 'full']); + }); - expect(codeSampleCatalogs.samplesFor(new Endpoint('GET', '/pets'))).to.be.empty; - expect(samples(codeSampleCatalogs, new Endpoint('POST', '/pets'))).to.deep.equal([ - { label: 'TypeScript', sources: { Example: 'b' } } - ]); + // One sample per method and path, which would be another API's call on all but one of the pages. + it('leaves out an operation two specs declare, in every language, and keeps the rest', () => { + const specs = [ + spec('alpha', 'alpha/alpha.json', ['GET /status', 'GET /alpha']), + spec('beta', 'beta/beta.json', ['GET /status']) + ]; + + const file = placed( + [ + catalog(Language.TYPESCRIPT, { + paths: { '/status': { GET: { Example: 'alpha status' } }, '/alpha': { GET: { Example: 'alpha' } } } + }), + catalog(Language.CSHARP, { paths: { '/status': { GET: { Example: 'Alpha status' } } } }) + ], + specs + ); + + expect(file).to.deep.equal({ paths: { '/alpha': { GET: [typescript('alpha')] } }, specs: {} }); + }); + + it('reads an empty set of samples by spec as none', () => { + const file = placed( + [catalog(Language.TYPESCRIPT, { paths: { '/pets': { GET: { Example: 'a' } } }, specs: {} })], + [calculator] + ); + + expect(file).to.deep.equal({ paths: { '/pets': { GET: [typescript('a')] } }, specs: {} }); + }); }); - it("writes each endpoint's samples keyed by path and upper-case method, languages in catalog order", () => { - const codeSampleCatalogs = new CodeSampleCatalogs([ - catalog(Language.TYPESCRIPT, { '/pets': { get: { Example: 'a' }, POST: { Example: 'b' } } }), - catalog(Language.CSHARP, { '/pets': { GET: { Example: 'c' } } }) - ]); - - expect(codeSampleCatalogs.toJson()).to.deep.equal({ - '/pets': { - GET: [ - { lang: 'typescript', label: 'TypeScript', sources: { Example: 'a' } }, - { lang: 'csharp', label: 'C#', sources: { Example: 'c' } } + describe('samples filed by spec', () => { + const orders = spec('orders', 'orders/openapi.yaml', ['GET /status', 'GET /orders']); + const inventory = spec('inventory', 'inventory/openapi.json', ['GET /status', 'GET /stock']); + + /** As the platform files the two: `paths` holds the first operation on `GET /status` it reached. */ + const filed = (language: Language, prefix: string) => + catalog(language, { + paths: { + '/status': { GET: { Example: `${prefix} orders status` } }, + '/orders': { GET: { Example: `${prefix} orders` } }, + '/stock': { GET: { Example: `${prefix} stock` } } + }, + specs: { + 'orders/openapi.yaml': { + paths: { + '/status': { GET: { Example: `${prefix} orders status` } }, + '/orders': { GET: { Example: `${prefix} orders` } } + } + }, + 'inventory/openapi.json': { + paths: { + '/status': { GET: { Example: `${prefix} inventory status` } }, + '/stock': { GET: { Example: `${prefix} stock` } } + } + } + } + }); + + it('gives each spec its own samples, its own call on an operation both declare, and none from paths', () => { + expect(placed([filed(Language.TYPESCRIPT, 'ts')], [orders, inventory])).to.deep.equal({ + paths: {}, + specs: { + orders: { + '/status': { GET: [typescript('ts orders status')] }, + '/orders': { GET: [typescript('ts orders')] } + }, + inventory: { + '/status': { GET: [typescript('ts inventory status')] }, + '/stock': { GET: [typescript('ts stock')] } + } + } + }); + }); + + it("keeps languages in catalog order within a spec's own samples", () => { + const file = placed([filed(Language.TYPESCRIPT, 'ts'), filed(Language.CSHARP, 'cs')], [orders, inventory]); + + expect(file.specs.inventory['/status'].GET).to.deep.equal([ + typescript('ts inventory status'), + csharp('cs inventory status') + ]); + }); + + it('gives a spec nothing for an operation its own samples leave out, though paths has it', () => { + const file = placed( + [ + catalog(Language.TYPESCRIPT, { + paths: { '/status': { GET: { Example: 'inventory status' } }, '/stock': { GET: { Example: 'stock' } } }, + specs: { 'inventory/openapi.json': { paths: { '/status': { GET: { Example: 'inventory status' } } } } } + }) ], - POST: [{ lang: 'typescript', label: 'TypeScript', sources: { Example: 'b' } }] - } + [inventory] + ); + + expect(file).to.deep.equal({ + paths: {}, + specs: { inventory: { '/status': { GET: [typescript('inventory status')] } } } + }); + }); + + it('gives a spec with no samples of its own none at all', () => { + const file = placed( + [ + catalog(Language.TYPESCRIPT, { + paths: { '/orders': { GET: { Example: 'orders' } }, '/stock': { GET: { Example: 'stock' } } }, + specs: { 'inventory/openapi.json': { paths: { '/stock': { GET: { Example: 'stock' } } } } } + }) + ], + [orders, inventory] + ); + + expect(file).to.deep.equal({ paths: {}, specs: { inventory: { '/stock': { GET: [typescript('stock')] } } } }); + }); + + it('matches a spec in a nested folder by its path from spec/', () => { + const nested = spec('orders', 'apis/orders/openapi.yaml', ['GET /orders']); + + const file = placed( + [ + catalog(Language.TYPESCRIPT, { + paths: { '/orders': { GET: { Example: 'orders' } } }, + specs: { 'apis/orders/openapi.yaml': { paths: { '/orders': { GET: { Example: 'orders' } } } } } + }) + ], + [nested] + ); + + expect(file.specs).to.deep.equal({ orders: { '/orders': { GET: [typescript('orders')] } } }); + }); + + it('matches a spec by its path only as the platform writes it', () => { + const group = { paths: { '/orders': { GET: { Example: 'orders' } } } }; + + const file = placed( + [ + catalog(Language.TYPESCRIPT, { + paths: {}, + specs: { + './orders/openapi.yaml': group, + 'orders\\openapi.yaml': group, + 'Orders/openapi.yaml': group, + orders: group + } + }) + ], + [orders] + ); + + expect(file).to.deep.equal({ paths: {}, specs: {} }); }); }); }); From 763b05c7a32df5345f3465cd15aa3b43c3a938a2 Mon Sep 17 00:00:00 2001 From: Muhammad Saeed Date: Tue, 27 Oct 2026 16:52:56 +0500 Subject: [PATCH 3/7] fix(portal): load an in an .mdx page from beside the page and under a path (#456) --- portal-template/remark-jsx-images.ts | 87 ++++++++++ portal-template/vite.config.ts | 3 +- src/types/portal/page.ts | 37 +++-- src/types/portal/portal-source.ts | 2 +- test/e2e/portal-build.test.ts | 17 ++ .../portal-template/remark-jsx-images.test.ts | 152 ++++++++++++++++++ .../default/src/content/authentication.mdx | 2 + .../default/src/content/mark.svg | 1 + .../subpath/src/content/figures.mdx | 8 + .../subpath/src/content/flow.png | Bin 0 -> 7028 bytes test/types/portal-source-context.test.ts | 14 +- test/types/portal/page.test.ts | 33 ++++ 12 files changed, 343 insertions(+), 13 deletions(-) create mode 100644 portal-template/remark-jsx-images.ts create mode 100644 test/portal-template/remark-jsx-images.test.ts create mode 100644 test/resources/portal-inputs/default/src/content/mark.svg create mode 100644 test/resources/portal-inputs/subpath/src/content/figures.mdx create mode 100644 test/resources/portal-inputs/subpath/src/content/flow.png diff --git a/portal-template/remark-jsx-images.ts b/portal-template/remark-jsx-images.ts new file mode 100644 index 000000000..225560f4a --- /dev/null +++ b/portal-template/remark-jsx-images.ts @@ -0,0 +1,87 @@ +import path from 'node:path'; + +/** An mdast node, as far as an `` written in an `.mdx` page and the import added for its file go. */ +interface MarkdownNode { + type: string; + name?: string | null; + attributes?: JsxAttribute[]; + value?: unknown; + data?: unknown; + children?: MarkdownNode[]; +} + +interface JsxAttribute { + type: string; + name?: string; + value?: unknown; +} + +// A scheme, a drive letter or a network path: none of them names a file the build imports. +const NOT_A_FILE = /^(?:[a-z][a-z\d+.-]*:|\/\/)/i; + +const estreeProgram = (statement: object) => ({ type: 'Program', sourceType: 'module', body: [statement] }); + +/** Bundles the file an `` names, as `remarkImage` does for Markdown images alone. page.ts checks the same. */ +export function remarkJsxImages({ publicDir }: { publicDir: string | false }) { + return (tree: MarkdownNode, { dirname }: { dirname?: string }) => { + if (dirname === undefined) return; + const imports: MarkdownNode[] = []; + for (const { attribute, address } of imageSources(tree)) { + const file = fileOf(address, publicDir, dirname); + if (file === null) continue; + // As `remarkImage` writes it, so a file on another drive is `./C:/…`, which static-images.ts resolves. + const relative = path.relative(dirname, file).replaceAll(path.sep, '/'); + const name = `__jsxImg${imports.length}`; + imports.push(importOf(name, relative.startsWith('../') ? relative : `./${relative}`)); + attribute.value = expressionOf(name); + } + tree.children?.unshift(...imports); + }; +} + +function* imageSources(node: MarkdownNode): Generator<{ attribute: JsxAttribute; address: string }> { + if ((node.type === 'mdxJsxFlowElement' || node.type === 'mdxJsxTextElement') && node.name === 'img') { + const src = node.attributes?.find(({ type, name }) => type === 'mdxJsxAttribute' && name === 'src'); + // A browser drops the spaces around an ``'s address; `remarkImage` keeps a Markdown image's. + if (typeof src?.value === 'string') yield { attribute: src, address: src.value.trim() }; + } + for (const child of node.children ?? []) yield* imageSources(child); +} + +function fileOf(url: string, publicDir: string | false, dirname: string): string | null { + const address = url.split(/[?#]/, 1)[0]; + if (address.length === 0 || address.endsWith('/') || NOT_A_FILE.test(address)) return null; + const decoded = decodedAddress(address); + if (!decoded.startsWith('/')) return path.join(dirname, decoded); + return publicDir === false ? null : path.join(publicDir, decoded); +} + +function decodedAddress(address: string): string { + try { + return decodeURI(address); + } catch { + return address; + } +} + +function importOf(name: string, source: string): MarkdownNode { + return { + type: 'mdxjsEsm', + value: `import ${name} from ${JSON.stringify(source)}`, + data: { + estree: estreeProgram({ + type: 'ImportDeclaration', + source: { type: 'Literal', value: source }, + specifiers: [{ type: 'ImportDefaultSpecifier', local: { type: 'Identifier', name } }] + }) + } + }; +} + +function expressionOf(name: string): MarkdownNode { + return { + type: 'mdxJsxAttributeValueExpression', + value: name, + data: { estree: estreeProgram({ type: 'ExpressionStatement', expression: { type: 'Identifier', name } }) } + }; +} diff --git a/portal-template/vite.config.ts b/portal-template/vite.config.ts index 57b4643cd..1151c6ff5 100644 --- a/portal-template/vite.config.ts +++ b/portal-template/vite.config.ts @@ -9,6 +9,7 @@ import { downloads } from './downloads.ts'; import { generatedPagesReload } from './generated-pages-reload.ts'; import { readBuildPaths, readPortalIdentity } from './portal-config.ts'; import { prerenderPages } from './prerender-pages.ts'; +import { remarkJsxImages } from './remark-jsx-images.ts'; import { specReload } from './spec-reload.ts'; import { remarkImageReferences } from './src/lib/remark-image-references.ts'; import { staticFunctionsBase } from './static-functions-base.ts'; @@ -36,7 +37,7 @@ export default defineConfig(async ({ command, isPreview }) => { mdxOptions: { remarkImageOptions: { publicDir: publicDir || undefined, external: false }, // A function, so it runs ahead of the preset's own plugins, `remarkImage` among them. - remarkPlugins: (plugins) => [remarkImageReferences, ...plugins] + remarkPlugins: (plugins) => [remarkImageReferences, [remarkJsxImages, { publicDir }], ...plugins] } } }), diff --git a/src/types/portal/page.ts b/src/types/portal/page.ts index 7e423b4b3..bb0bb21c8 100644 --- a/src/types/portal/page.ts +++ b/src/types/portal/page.ts @@ -11,9 +11,9 @@ export interface PageFrontMatter { title: string; } -/** A Markdown image the build imports, where it looks for the file, and the path it looks up there. */ +/** An image the build imports, where it looks for the file, and the path it looks up there. */ export interface PageImage { - /** As the page writes it. */ + /** As the page writes it: a Markdown image's address, or the `src` of an `` in an `.mdx` page, trimmed. */ url: string; line: number; /** An image written as `/…` is read from the static directory, any other from beside its page. */ @@ -36,6 +36,8 @@ interface MarkdownNode { type: string; url?: string; identifier?: string; + name?: string | null; + attributes?: { type: string; name?: string; value?: unknown }[]; position?: { start: { line: number } }; children?: MarkdownNode[]; } @@ -128,12 +130,7 @@ async function imagesIn(body: string, isMdx: boolean): Promise { const images: PageImage[] = []; const visit = (node: MarkdownNode) => { - const url = - node.type === 'image' - ? node.url - : node.type === 'imageReference' - ? definitions.get(node.identifier ?? '') - : undefined; + const url = addressOf(node, definitions); const image = url !== undefined ? located(url) : null; if (image !== null) { images.push({ ...image, line: node.position?.start.line ?? 0 }); @@ -144,13 +141,33 @@ async function imagesIn(body: string, isMdx: boolean): Promise { return images; } +function addressOf(node: MarkdownNode, definitions: Map): string | undefined { + switch (node.type) { + case 'image': + return node.url; + case 'imageReference': + return definitions.get(node.identifier ?? ''); + // remark-jsx-images.ts imports these too. + case 'mdxJsxFlowElement': + case 'mdxJsxTextElement': { + const src = node.attributes?.find(({ type, name }) => type === 'mdxJsxAttribute' && name === 'src')?.value; + // A browser drops the spaces around an ``'s address; `remarkImage` keeps a Markdown image's. + return node.name === 'img' && typeof src === 'string' ? src.trim() : undefined; + } + default: + return undefined; + } +} + function located(url: string): Omit | null { const address = url.split(/[?#]/, 1)[0]; if (address.length === 0 || address.endsWith('/') || NOT_A_FILE.test(address)) { return null; } - const path = decodedAddress(address); - return path.startsWith('/') ? { url, from: 'static', path: path.replace(/^\/+/, '') } : { url, from: 'page', path }; + const decoded = decodedAddress(address); + return decoded.startsWith('/') + ? { url, from: 'static', path: decoded.replace(/^\/+/, '') } + : { url, from: 'page', path: decoded }; } function decodedAddress(address: string): string { diff --git a/src/types/portal/portal-source.ts b/src/types/portal/portal-source.ts index d96f48377..bf5c8ccf8 100644 --- a/src/types/portal/portal-source.ts +++ b/src/types/portal/portal-source.ts @@ -87,7 +87,7 @@ export interface ReservedAddressPage { /** Pages of the user's that would be served at one address, which only one of them can have. */ export type SharedAddress = Shared; -/** A page's Markdown image that the build would import and not find, which fails the whole build. */ +/** A page's image that the build would import and not find, which fails the whole build. */ export interface MissingImage { page: FilePath; line: number; diff --git a/test/e2e/portal-build.test.ts b/test/e2e/portal-build.test.ts index cb2491bd1..9550eec1f 100644 --- a/test/e2e/portal-build.test.ts +++ b/test/e2e/portal-build.test.ts @@ -212,6 +212,11 @@ const stylesheetOf = (output: DirectoryPath) => { expect(exists('images/logo.png')).to.be.true; }); + // Hosted at the root too, an `` is bundled (remark-jsx-images.ts); a file this small is inlined. + it("bundles an 's file", () => { + expect(read('authentication/index.html')).to.match(/]* src="data:image\/svg\+xml,[^"]+"[^>]* width="48"/); + }); + it('writes the not-found page static hosts serve for unknown paths', () => { expect(exists('404.html')).to.be.true; }); @@ -814,6 +819,7 @@ const stylesheetOf = (output: DirectoryPath) => { for (const page of [ 'index.html', 'authentication/index.html', + 'figures/index.html', 'api/apimatic-calculator/simple-calculator/Calculate/index.html', 'sdks/index.html', 'sdks/typescript/index.html', @@ -844,6 +850,14 @@ const stylesheetOf = (output: DirectoryPath) => { expect(read('authentication/index.html')).to.match(/]* src="\/api\/assets\/diagram[^"]*\.png"/); }); + // `remarkImage` bundles Markdown images alone; the template bundles these (remark-jsx-images.ts). + it('bundles an in an .mdx page, from the static directory or beside its page', () => { + const page = read('figures/index.html'); + + expect(page).to.match(/]* src="\/api\/assets\/diagram[^"]*\.png"[^>]* width="320"/); + expect(page).to.match(/]* src="\/api\/assets\/flow[^"]*\.png"/); + }); + // Fumadocs swapped a bundled image for its import's name, `` (fuma-nama/fumadocs#3662). it('keeps the images as written in the Markdown twins and llms-full.txt', () => { const full = read('llms-full.txt'); @@ -851,8 +865,11 @@ const stylesheetOf = (output: DirectoryPath) => { expect(read('index.md')).to.contain('![How a calculation flows](/images/diagram.png)'); expect(read('authentication.md')).to.contain('![The flow, by reference][flow]'); expect(read('authentication.md')).to.contain('[flow]: /images/diagram.png'); + expect(read('figures.md')).to.contain('The steps'); expect(full).to.contain('![How a calculation flows](/images/diagram.png)'); expect(full).to.not.contain('__img'); + expect(full).to.not.contain('__jsxImg'); }); // Fumadocs' own renderer fails the whole page over an image in a description; `ApiMarkdown` stands in for it. diff --git a/test/portal-template/remark-jsx-images.test.ts b/test/portal-template/remark-jsx-images.test.ts new file mode 100644 index 000000000..25ee4818d --- /dev/null +++ b/test/portal-template/remark-jsx-images.test.ts @@ -0,0 +1,152 @@ +import path from 'node:path'; +import { expect } from 'chai'; +import { remark } from 'remark'; +import remarkMdx from 'remark-mdx'; +import { FileName } from '../../src/types/file/fileName'; +import { parsePage } from '../../src/types/portal/page'; +import { remarkJsxImages } from '../../portal-template/remark-jsx-images'; + +describe('the tags of the .mdx content pages', () => { + const project = path.resolve('/portal'); + const staticDirectory = path.join(project, 'static'); + const pageDirectory = path.join(project, 'content', 'guides'); + const processor = remark().use(remarkMdx); + + /** The page once the step has run, written back as MDX, the imports it adds at the top. */ + const transformed = (mdx: string, publicDir: string | false = staticDirectory): string => { + const tree = processor.parse(mdx); + remarkJsxImages({ publicDir })(tree, { dirname: pageDirectory }); + return processor.stringify(tree); + }; + + it('imports the file beside the page or in the static directory, keeping the other attributes', () => { + expect(transformed('Flow\n\n\n')).to.equal( + 'import __jsxImg0 from "./flow.png"\n\nimport __jsxImg1 from "../../static/images/logo.png"\n\n' + + 'Flow\n\n\n' + ); + }); + + it('imports an inside a line of text, and one inside a component', () => { + expect( + transformed('Inline here.\n\n\n \n\n') + ).to.equal( + 'import __jsxImg0 from "../shared.png"\n\nimport __jsxImg1 from "./diagram.png"\n\n' + + 'Inline here.\n\n\n \n\n' + ); + }); + + it('reads the address without the spaces around it or its query or fragment, and decoded', () => { + expect(transformed('\n')).to.equal( + 'import __jsxImg0 from "./my flow.png"\n\n\n' + ); + }); + + const namingNoFile = [ + '', + '', + '', + '', + '', + '', + 'No address' + ]; + + it('leaves an address that names no file as written, and an without one', () => { + const mdx = namingNoFile.join('\n\n') + '\n'; + + expect(transformed(mdx)).to.equal(mdx); + }); + + it('leaves other tags as written, a component named Img among them', () => { + const mdx = '\n\n\n\n