Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,071 changes: 1,071 additions & 0 deletions .ai/plans/portal-subpath-hosting.md

Large diffs are not rendered by default.

9 changes: 4 additions & 5 deletions .ai/skills/action.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,17 +171,16 @@ export class {PascalName}Action {
// 3. Business logic wrapped in withDirPath for temp directory
return await withDirPath(async (tempDirectory) => {
// 4. Resolve resources / prepare context
// const resourceContext = new ResourceContext(tempDirectory);
// const specFileDirResult = await resourceContext.resolveTo(resourcePath);
// if (specFileDirResult.isErr()) {
// this.prompts.networkError(specFileDirResult.error);
// const spec = await ResourceContext.resolveTo(resourcePath, tempDirectory);
// if (spec.isErr()) {
// this.prompts.specUnavailable(spec.error);
// return ActionResult.failed();
// }

// 5. Service call via prompts spinner
// const response = await this.prompts.spinnerMethod(
// this.someService.doSomething({
// file: specFileDirResult.value,
// file: spec.value.file(),
// commandMetadata: this.commandMetadata,
// authKey: this.authKey
// })
Expand Down
21 changes: 19 additions & 2 deletions .github/workflows/portal_tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ jobs:
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
repository: apimatic/v4-test-automation
ref: feat/functional-tests
path: tests

- name: install test dependencies
Expand Down Expand Up @@ -166,10 +167,25 @@ jobs:
PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}\.playwright-browsers
run: pnpm test:visual

# Functional runs against the suite's auth build, whose playground calls
# the API Tester (tests/api-tester/) on localhost:3000.
# Playwright starts the API Tester itself (webServer in playwright.config.ts),
# because a server started in its own step doesn't outlive that step.

- name: generate the auth portal
id: generate-auth
if: ${{ !cancelled() }}
working-directory: tests
env:
PORTAL: auth
run: pnpm portal:generate

- name: functional
if: ${{ !cancelled() && steps.generate.outcome == 'success' }}
if: ${{ !cancelled() && steps.generate-auth.outcome == 'success' }}
working-directory: tests
shell: bash
env:
PORTAL: auth
PORTAL_PREBUILT: '1'
PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}\.playwright-browsers
run: pnpm test:functional
Expand All @@ -189,6 +205,7 @@ jobs:
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: test-results
path: tests/test-results/
path: |
tests/test-results/
retention-days: 30
if-no-files-found: ignore
4 changes: 4 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ _Avoid_: Build directory, build project, build tree
One API description in `spec/`, in any format APIMatic can transform (OpenAPI 3.x, Swagger, Postman Collections, RAML, API Blueprint, etc.), which becomes one section of reference pages in the portal.
_Avoid_: Specification file, API definition

**Spec directory**:
The `spec/` directory in the source directory: every spec, the files they reference, and anything else kept beside them.
_Avoid_: Specs folder, spec folder

**Endpoint**:
An operation addressed by its HTTP method and path template, exactly as the spec writes them.
_Avoid_: Operation ID, route
Expand Down
79 changes: 15 additions & 64 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,63 +21,6 @@ To get started with APIMatic's CLI using a step by step wizard, run the followin
$ apimatic quickstart
```

# Upgrading from 1.x

Documentation portals are now built on your machine from a `src/` directory, and
`APIMATIC-BUILD.json` no longer configures them:

- Describe the portal in the `portal` block of `src/apimatic.json`: `site` (its name, address
and description), `brand` (logo, favicon, primary colour and colour mode), `navigation`
(header links) and `ai` (the page actions). Running `apimatic quickstart` scaffolds the block
with every default spelled out, and the file's `$schema` lets your editor complete and check it.
- A portal also needs the project's SDK languages, at least one, in the same file's
`languages` block, for example `"languages": { "typescript": {} }`; `csharp`, `python` and
`typescript` are available for now. `plugin generate` and `sdk publish` both write to it, and
the context plugin reads it too, so name only the languages you ship. The same file carries
the plugin's identity in `plugin`; `src/plugin-config.json` is no longer read, so run
`plugin generate` and `sdk publish` again after upgrading and delete the old file.
- Put OpenAPI documents in `src/spec/`, Markdown pages in `src/content/` and images and other
files in `src/static/`.
- Every page starts with front matter giving its `title`, which names it in the sidebar.
Pages written for 1.x had none; the CLI lists each page that needs one before it builds.
```md
---
title: Getting started
---
```
- Page order comes from a `nav.json` beside your pages, listing them by file name, and a
`title` there names the folder it sits in. A folder links to its `index.md`; without one it
is only a heading.
- The top level of the portal is shown as tabs, which `src/content/nav.json` decides with two
lists. `tabs` names the tabs after Home, in order, and nothing else is one: a folder directly
under `src/content/`, and the sections the CLI makes, placed with `apimatic:sdks`,
`apimatic:plugin` and `apimatic:api`. Home comes first, opens on `index.md`, and holds
everything `tabs` does not name: every page at the top level, every other folder, and every
section not named as a tab, each at the address it always has. Its `pages` orders that sidebar
as any other `nav.json` orders its folder, a token standing for its section, and the file's
`title` names it; what `pages` does not name follows in alphabetical order, pages before
folders. A file with no `tabs`, or no file at all, makes no tab, so Home stands alone and
there is no tab bar; the build says so once, with the line that makes tabs of the sections
and the reference `pages` does not place. A new portal starts with the three sections as
tabs.
```json
{
"title": "Overview",
"tabs": ["tutorials", "apimatic:sdks", "apimatic:api"],
"pages": ["index", "authentication", "guides", "apimatic:plugin", "..."]
}
```
- The SDKs tab lists a card per language in the `languages` block, offering its download and,
once `sdk publish` records a release, its install command, source repository and package, and
gives each language a page carrying its SDK's getting-started docs. The Context Plugin tab,
with the plugin's install command, appears when there is a `plugin` block, or a `pluginUrl` in
the `portal` block for a plugin hosted elsewhere. Their addresses, `/sdks` and
`/context-plugin`, are kept for them, so a page in `src/content/` that would be served there
is refused.
- `portal toc new`, `portal recipe new` and `portal copilot` are gone, and `portal serve` no
longer takes `--destination` or `--no-reload`. Run `apimatic autocomplete --refresh-cache`
to drop the removed commands from shell completion.

# Usage
<!-- usage -->
```sh-session
Expand Down Expand Up @@ -151,19 +94,26 @@ Validate API specification for syntactic and semantic correctness

```
USAGE
$ apimatic api validate [--file <value>] [--url <value>] [-k <value>]
$ apimatic api validate [--file <value> | --url <value> | -i <value>] [-k <value>]

FLAGS
-i, --input=<value> [default: ./] path to the parent directory containing the 'src' directory, which includes API
specifications and configuration files.
-k, --auth-key=<value> override current authentication state with an authentication key.
--file=<value> Path to the API specification file to validate
--url=<value> URL to the API specification file to validate (publicly accessible)

DESCRIPTION
Validate API specification for syntactic and semantic correctness

Validate your API specification to ensure it adheres to syntactic and semantic standards.
Validate the API specification in your 'src/spec' directory, or the one --file or --url points to, to ensure it
adheres to syntactic and semantic standards.

EXAMPLES
apimatic api validate

apimatic api validate --input=./

apimatic api validate --file=./specs/sample.json

apimatic api validate --url="https://petstore.swagger.io/v2/swagger.json"
Expand Down Expand Up @@ -361,9 +311,10 @@ DESCRIPTION

Builds a documentation portal from the OpenAPI documents and Markdown pages in your 'src' directory.

The portal is built on your machine and written as static files you can host anywhere. Configure it with
'src/apimatic.json', whose 'languages' block gives the portal a page for each SDK language, and whose 'plugin' block,
or a 'pluginUrl' in its 'portal' block for a plugin hosted elsewhere, a page for the context plugin.
The portal is built on your machine and written as static files for any static host, served at its root or, when
'portal.site.url' has a path such as 'https://example.com/docs', at that path. Configure it with 'src/apimatic.json',
whose 'languages' block gives the portal a page for each SDK language, and whose 'plugin' block, or a 'pluginUrl' in
its 'portal' block for a plugin hosted elsewhere, a page for the context plugin.

EXAMPLES
apimatic portal generate
Expand Down Expand Up @@ -397,8 +348,8 @@ DESCRIPTION
pages in 'src/content', reorder them in a 'nav.json', or change the 'portal', 'languages' or 'plugin' block of
'apimatic.json'.

Adding a language or a 'plugin' block, adding or removing a page in 'src/content', creating 'src/static', or changing
which documents are in 'src/spec', needs the preview restarted.
Adding a language or a 'plugin' block, adding or removing a page in 'src/content', creating 'src/static', changing
which documents are in 'src/spec', or changing the path in 'portal.site.url', needs the preview restarted.

Nothing is written to disk; run 'apimatic portal generate' to produce the static files.

Expand Down
4 changes: 2 additions & 2 deletions apimatic.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,8 @@
},
"url": {
"type": "string",
"description": "The address the portal is hosted at, without a path, for example 'https://docs.example.com'.",
"pattern": "^\\s*[hH][tT][tT][pP][sS]?://[^\\s/?#]+/?\\s*$"
"description": "The address the portal is hosted at, for example 'https://docs.example.com' or 'https://example.com/docs'.",
"pattern": "^\\s*[hH][tT][tT][pP][sS]?://[^\\s/?#\\\\]+(?:/(?!\\.{1,2}(?:/|\\s*$))(?![A-Za-z0-9._~-]*\\.[hH][tT][mM][lL]?/?\\s*$)[A-Za-z0-9._~-]+)*/?\\s*$"
},
"description": {
"type": "string",
Expand Down
71 changes: 31 additions & 40 deletions portal-template/dependency-directories.ts
Original file line number Diff line number Diff line change
@@ -1,54 +1,45 @@
import fs from 'node:fs';
import path from 'node:path';

/**
* The installations the project's dependencies resolve to. Vite serves a file from outside
* its allow list only once the module importing it has been transformed, an order a browser
* cache skips: Safari, holding the importer from an earlier preview on the same port, asked
* for the TanStack dev entry first and was refused, leaving a blank page. Naming where the
* links really lead keeps every dependency served whatever the browser asks for first.
*/
export function dependencyDirectories(projectDirectory: string): string[] {
const directories = new Set<string>();
for (const packagePath of packagePaths(path.join(projectDirectory, 'node_modules'))) {
const installed = realPath(packagePath);
if (installed !== undefined) {
directories.add(outermostNodeModules(installed));
// Safari runs a cached entry before its importer is transformed (apimatic-io#2287).
export function dependencyDirectories(portalProjectDirectory: string): string[] {
const modules = path.join(portalProjectDirectory, 'node_modules');
return narrowest(
packageNames(modules)
.filter((name) => fs.lstatSync(path.join(modules, name)).isSymbolicLink())
.map((name) => holdingDirectory(fs.realpathSync.native(path.join(modules, name)), name))
);
}

/** The one directory holding them all: the CLI's `node_modules` under npm, the store under pnpm. Each alone when only the disk's root would. */
function narrowest(directories: string[]): string[] {
let common: string | undefined = directories[0];
for (const directory of directories.slice(1)) {
while (common !== undefined && !isWithin(common, directory)) {
common = isRoot(path.dirname(common)) ? undefined : path.dirname(common);
}
}
return [...directories];
return common === undefined ? directories : [common];
}

function packagePaths(modules: string): string[] {
return entries(modules).flatMap((name) => {
const packagePath = path.join(modules, name);
return name.startsWith('@') ? entries(packagePath).map((scoped) => path.join(packagePath, scoped)) : [packagePath];
});
function packageNames(modules: string): string[] {
return fs
.readdirSync(modules)
.flatMap((name) =>
name.startsWith('@') ? fs.readdirSync(path.join(modules, name)).map((scoped) => `${name}/${scoped}`) : [name]
);
}

function entries(directory: string): string[] {
try {
return fs.readdirSync(directory).filter((name) => !name.startsWith('.'));
} catch {
return [];
}
/** Where a package and the dependencies installed beside it sit, its scope included. */
function holdingDirectory(packageDirectory: string, name: string): string {
return path.resolve(packageDirectory, ...name.split('/').map(() => '..'));
}

function realPath(target: string): string | undefined {
try {
return fs.realpathSync(target);
} catch {
return undefined;
}
function isRoot(directory: string): boolean {
return path.dirname(directory) === directory;
}

/** The widest installation around a package: under pnpm the whole store sits inside one `node_modules` too. */
function outermostNodeModules(packageDirectory: string): string {
let found = packageDirectory;
for (let parent = path.dirname(packageDirectory); parent !== path.dirname(parent); parent = path.dirname(parent)) {
if (path.basename(parent) === 'node_modules') {
found = parent;
}
}
return found;
function isWithin(directory: string, target: string): boolean {
const relative = path.relative(directory, target);
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
}
9 changes: 6 additions & 3 deletions portal-template/downloads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { createReadStream } from 'node:fs';
import { cp, readdir } from 'node:fs/promises';
import path from 'node:path';
import type { Plugin } from 'vite';
import { withBasePath } from './src/lib/base-path';

/** Where the site offers the downloads: a name the user's static directory is not expected to use. */
export const DOWNLOADS_ADDRESS = '__downloads';
Expand All @@ -15,7 +16,8 @@ export function downloads(directory: string | null): Plugin {
name: 'apimatic:downloads',
async configureServer(server) {
if (directory === null) return;
const files = await filesByAddress(directory);
// Under the base only, as once built: this runs ahead of Vite's own base handling.
const files = await filesByAddress(directory, server.config.base);
server.middlewares.use((request, response, next) => {
const file = files.get(decodeURIComponent(new URL(request.url ?? '/', 'http://localhost').pathname));
if (file === undefined) return next();
Expand All @@ -30,12 +32,13 @@ export function downloads(directory: string | null): Plugin {
};
}

async function filesByAddress(directory: string): Promise<Map<string, string>> {
async function filesByAddress(directory: string, base: string): Promise<Map<string, string>> {
const entries = await readdir(directory, { recursive: true, withFileTypes: true });
const files = new Map<string, string>();
for (const entry of entries.filter((candidate) => candidate.isFile())) {
const file = path.join(entry.parentPath, entry.name);
files.set(`/${DOWNLOADS_ADDRESS}/${path.relative(directory, file).split(path.sep).join('/')}`, file);
const address = `/${DOWNLOADS_ADDRESS}/${path.relative(directory, file).split(path.sep).join('/')}`;
files.set(withBasePath(address, base), file);
}
return files;
}
2 changes: 2 additions & 0 deletions portal-template/portal-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import type { Portal } from './src/lib/portal-types';

/** Written by the CLI next to this file before every build or dev-server start. */
export interface BuildPaths {
/** Vite's `base`, the path of the site address that every page and asset is served under: `/`, or `/docs/`. */
base: string;
/** Section slug -> absolute path of an OpenAPI document. */
specs: Record<string, string>;
/** Absolute path of the code samples, keyed by path then method; null when there are none. */
Expand Down
10 changes: 8 additions & 2 deletions portal-template/prerender-pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { readdir } from 'node:fs/promises';
import path from 'node:path';
import { getSlugs, loader } from 'fumadocs-core/source';
import type { BuildPaths } from './portal-config.ts';
import { withBasePath } from './src/lib/base-path';
import { oauthCallbackPath } from './src/lib/oauth-callback';
import { openApiSection } from './src/lib/openapi-section.server';

const CONTENT_EXTENSIONS = new Set(['.md', '.mdx']);
Expand All @@ -19,7 +21,8 @@ export async function prerenderPages(config: BuildPaths, siteUrl: string | null)
// Both need absolute URLs, so they are only emitted for a portal that declares its address.
if (siteUrl) {
urls.add('/sitemap.xml');
urls.add('/robots.txt');
// Crawlers read it only at the root of a host.
if (config.base === '/') urls.add('/robots.txt');
}

for (const url of await contentUrls(config.contentDir)) urls.add(url);
Expand All @@ -35,8 +38,11 @@ export async function prerenderPages(config: BuildPaths, siteUrl: string | null)
if (url === '/') urls.add('/index.md');
else if (!/\.(txt|xml|json)$/.test(url)) urls.add(`${url}.md`);
}
// After the twins, since it is no page of the portal's and has none.
urls.add(oauthCallbackPath);

return [...urls].map((url) => ({ path: url }));
// Served paths, or TanStack takes a page starting like the base (`/api/…` under `/api`) as already under it.
return [...urls].map((url) => ({ path: withBasePath(url, config.base) }));
}

async function contentUrls(contentDir: string): Promise<string[]> {
Expand Down
32 changes: 32 additions & 0 deletions portal-template/src/components/api-markdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { createMarkdownRenderer } from 'fumadocs-core/content/md';
import { remarkGfm } from 'fumadocs-core/mdx-plugins/remark-gfm';
import { DynamicCodeBlock } from 'fumadocs-ui/components/dynamic-codeblock';
import defaultMdxComponents from 'fumadocs-ui/mdx';
import { Children, type ComponentProps, type ReactElement } from 'react';
import { withBasePath } from '@/lib/base-path';
import { languageOf } from '@/lib/code-titles';

const { Markdown } = createMarkdownRenderer({ remarkPlugins: [remarkGfm] });

const components = {
...defaultMdxComponents,
// A root-relative address names a file in the static directory, which the host serves under the base.
img: ({ src, ...props }: ComponentProps<'img'>) => (
<img {...props} src={typeof src === 'string' && /^\/(?!\/)/.test(src) ? withBasePath(src) : src} />
),
pre: CodeBlock
};

/** The specification's descriptions as Fumadocs renders them, but for its `img: undefined`, which fails the page. */
export function ApiMarkdown({ md }: { md: string }) {
return <Markdown components={components}>{md}</Markdown>;
}

/** A fenced block, highlighted as Fumadocs highlights one in a description. */
function CodeBlock({ children }: ComponentProps<'pre'>) {
const code = Children.only(children) as ReactElement<{ className?: string; children?: unknown }>;
const text = code.props.children;
if (typeof text !== 'string') return null;
const language = languageOf(code.props.className?.split(' ') ?? []) ?? 'text';
return <DynamicCodeBlock lang={language} code={text.trimEnd()} />;
}
Loading
Loading