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 change: 0 additions & 1 deletion .github/workflows/portal_tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,6 @@ 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
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,10 @@ DESCRIPTION

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

Each OpenAPI document at the top of 'src/spec' gets a section of the API reference, and the SDKs are built from one of
them. To build the SDKs from several, put each in a folder of its own and set 'MergeConfiguration.MergeApis' to true
in an 'APIMATIC-META.json' beside the folders; each folder then gets a section.

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
Expand Down Expand Up @@ -349,7 +353,8 @@ DESCRIPTION
'apimatic.json'.

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.
which documents are in 'src/spec' or which folders its 'APIMATIC-META.json' merges, 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
2 changes: 2 additions & 0 deletions portal-template/portal-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ export interface BuildPaths {
base: string;
/** Section slug -> absolute path of an OpenAPI document. */
specs: Record<string, string>;
/** 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. */
codeSamples: string | null;
/** Absolute. Always exists, and may be empty. */
Expand Down
21 changes: 12 additions & 9 deletions portal-template/spec-reload.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,27 @@
import path from 'node:path';
import type { Plugin } from 'vite';

/** The extensions of the files a specification is read from and refers to: the CLI's own list, held to it by a test. */
export const SPEC_FILE_EXTENSIONS = ['.json', '.yaml', '.yml'];

/**
* Reloads the preview when a file beside a specification changes. The specifications are read
* at run time rather than imported, so Vite's module graph never learns of them, and the
* reference pages are built once per server module evaluation.
* Reloads the preview when a specification file in the specification directory changes, as a document may refer
* to parts kept in another folder there. The specifications are read at run time rather than imported, so Vite's
* module graph never learns of them, and the reference pages are built once per evaluation.
*/
export function specReload(specs: Record<string, string>): Plugin {
const directories = [...new Set(Object.values(specs).map((file) => path.posix.dirname(file)))];
const isInSpecDirectory = (file: string) => directories.some((directory) => file.startsWith(`${directory}/`));
export function specReload(specDirectory: string): Plugin {
const isSpecFile = (file: string) =>
file.startsWith(`${specDirectory}/`) &&
SPEC_FILE_EXTENSIONS.some((extension) => file.toLowerCase().endsWith(extension));

return {
name: 'apimatic:spec-reload',
apply: 'serve',
configureServer(server) {
server.watcher.add(directories);
server.watcher.add(specDirectory);
},
// Sent to each environment: the server runner re-evaluates every module, the browser reloads.
hotUpdate({ file }) {
if (!isInSpecDirectory(file)) return;
if (!isSpecFile(file)) return;
this.environment.hot.send({ type: 'full-reload' });
return [];
}
Expand Down
2 changes: 1 addition & 1 deletion portal-template/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export default defineConfig(async () => {
}
}
}),
specReload(paths.specs),
specReload(paths.specDir),
tailwindcss(),
tanstackStart({
// Without a mask path the shell is rendered at "/" and no index.html is written;
Expand Down
3 changes: 3 additions & 0 deletions src/actions/portal/prepare-project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@ export class PreparePortalProjectAction {
}
this.prompts.filesShadowedByStatic(source.value.shadowedFiles);
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();
Expand Down
2 changes: 2 additions & 0 deletions src/commands/portal/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export default class PortalGenerate extends Command {

static readonly description = `Builds a documentation portal from the OpenAPI documents and Markdown pages in your 'src' directory.

Each OpenAPI document at the top of 'src/spec' gets a section of the API reference, and the SDKs are built from one of them. To build the SDKs from several, put each in a folder of its own and set 'MergeConfiguration.MergeApis' to true in an 'APIMATIC-META.json' beside the folders; each folder then gets a section.

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.`;

static readonly cmdTxt = format.cmd('apimatic', 'portal', 'generate');
Expand Down
2 changes: 1 addition & 1 deletion src/commands/portal/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export default class PortalServe extends Command {

static readonly description = `Serves the portal described by 'src/apimatic.json' from your machine, reloading the browser as you edit the Markdown 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', changing which documents are in 'src/spec', or changing the path in 'portal.site.url', 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 which folders its 'APIMATIC-META.json' merges, 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
26 changes: 17 additions & 9 deletions src/infrastructure/file-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,20 +165,28 @@ export class FileService {

// By code point first: Windows and macOS open `logo.png` for `Logo.PNG`, where a web server would not.
public async spelledOnDisk(root: DirectoryPath, file: FilePath): Promise<FilePath | null> {
const names = path.relative(root.toString(), file.toString()).split(path.sep);
const fileName = names.pop() ?? '';
let directory = root;
for (const name of names) {
const { subDirectories } = await this.listEntries(directory);
const directory = await this.directorySpelledOnDisk(root, file.directory());
if (directory === null) {
return null;
}
const { fileNames } = await this.listEntries(directory);
const match = spelling(file.name().toString(), fileNames, (candidate) => candidate.toString());
return match === undefined ? null : new FilePath(directory, match);
}

/** `directory`, at or below `root`, as each folder below `root` is spelt on disk; null when one is not there. */
public async directorySpelledOnDisk(root: DirectoryPath, directory: DirectoryPath): Promise<DirectoryPath | null> {
const relative = path.relative(root.toString(), directory.toString());
let found = root;
for (const name of relative === '' ? [] : relative.split(path.sep)) {
const { subDirectories } = await this.listEntries(found);
const match = spelling(name, subDirectories, (subDirectory) => path.basename(subDirectory.toString()));
if (match === undefined) {
return null;
}
directory = match;
found = match;
}
const { fileNames } = await this.listEntries(directory);
const match = spelling(fileName, fileNames, (candidate) => candidate.toString());
return match === undefined ? null : new FilePath(directory, match);
return found;
}

// The direct children of `dir`, split into files and directories; both empty when it cannot
Expand Down
7 changes: 6 additions & 1 deletion src/infrastructure/portal-project-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ export interface PortalProjectPaths {
export interface PortalBuildPaths {
base: string;
specs: Record<string, string>;
specDir: string;
codeSamples: string | null;
contentDir: string;
generatedDir: string;
Expand Down Expand Up @@ -127,7 +128,10 @@ export class PortalProjectService {
projectDirectory,
source,
await this.contentDirectory(projectDirectory, source),
await this.writeCodeSamples(projectDirectory, artifacts.codeSampleCatalogs),
await this.writeCodeSamples(
projectDirectory,
artifacts.codeSampleCatalogs.without(source.sharedEndpoints.map(({ key }) => key))
),
await this.writeDownloads(projectDirectory, artifacts)
);

Expand Down Expand Up @@ -281,6 +285,7 @@ export class PortalProjectService {
const configuration: PortalBuildPaths = {
base: source.config.siteAddress()?.path() ?? '/',
specs,
specDir: source.specDirectory.toPosix(),
codeSamples: codeSamples === null ? null : codeSamples.toPosix(),
contentDir: contentDirectory.toPosix(),
generatedDir: projectDirectory.join(GENERATED_DIRECTORY_NAME).toPosix(),
Expand Down
24 changes: 22 additions & 2 deletions src/prompts/portal/prepare-project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,23 @@ import { ServiceError } from '../../infrastructure/service-error.js';
import { PortalAuthorizationFailure } from '../../infrastructure/services/portal-authorization-service.js';
import { DirectoryPath } from '../../types/file/directoryPath.js';
import { FileName } from '../../types/file/fileName.js';
import { FilePath } from '../../types/file/filePath.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 } from '../../types/portal/portal-source.js';
import { PortalSourceProblem, SharedEndpoint } 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 { reportContentNotices, reportShadowedFiles, reportSourceProblem } from './source.js';
import {
reportContentNotices,
reportLeftOutSpecs,
reportShadowedFiles,
reportSharedEndpoints,
reportSourceProblem,
reportUnmergedSpecs
} from './source.js';

/** What both `portal generate` and `portal serve` say while the project they share is prepared. */
export class PreparePortalProjectPrompts {
Expand Down Expand Up @@ -48,6 +56,18 @@ export class PreparePortalProjectPrompts {
reportContentNotices(notices, sourceDirectory);
}

public unmergedSpecs(files: FilePath[], sourceDirectory: DirectoryPath) {
reportUnmergedSpecs(files, sourceDirectory);
}

public leftOutSpecs(files: FilePath[], sourceDirectory: DirectoryPath) {
reportLeftOutSpecs(files, sourceDirectory);
}

public sharedEndpoints(shared: SharedEndpoint[], sourceDirectory: DirectoryPath) {
reportSharedEndpoints(shared, sourceDirectory);
}

public unplacedSamples(endpoints: string[]) {
reportUnplacedSamples(endpoints);
}
Expand Down
4 changes: 3 additions & 1 deletion src/prompts/portal/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { UrlPath } from '../../types/file/urlPath.js';
import { ContentNotices } from '../../types/portal/content-notices.js';
import { MissingArtifacts } from '../../types/portal/generated-pages.js';
import { NAVIGATION_FILE_NAME } from '../../types/portal/portal-navigation.js';
import { METADATA_FILE_NAME } from '../../types/project-layout.js';
import { ContentProblem, PortalSourceProblem } from '../../types/portal/portal-source.js';
import { PortalDevServer, PortalDevServerFailure } from '../../infrastructure/portal-dev-server-service.js';
import { Result } from 'neverthrow';
Expand Down Expand Up @@ -56,7 +57,8 @@ export class PortalServePrompts {
'',
`Restart the preview after adding a language or a ${f.var('plugin')} block (its SDK or plugin is ` +
`fetched at startup), adding or removing a page, creating ${staticPath(sourceDirectory)}, ` +
`changing which documents are in ${specPath(sourceDirectory)}, or changing the path in ` +
`changing which documents are in ${specPath(sourceDirectory)} or which folders its ` +
`${f.var(METADATA_FILE_NAME)} merges, or changing the path in ` +
`${f.var('portal.site.url')}.`,
'',
'Press CTRL+C to stop the server.'
Expand Down
Loading
Loading