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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -349,8 +349,8 @@ DESCRIPTION
Preview your API Documentation Portal with live reload.

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'.
pages in 'src/content', reorder them in a 'nav.json', edit the documents in 'src/spec', 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 which folders its 'APIMATIC-META.json' merges, or changing the path in
Expand Down
10 changes: 9 additions & 1 deletion portal-template/src/lib/openapi.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ export type OpenApiSource = StaticSource<{ pageData: OpenAPIPageData; metaData:
* Markdown page.
*/
export async function openApiSource(): Promise<OpenApiSource> {
const sections = await Promise.all(Object.entries(specs).map(([slug, file]) => openApiSection(slug, file, codeSamplesFile)));
const loading = Object.entries(specs).map(([slug, file]) => openApiSection(slug, file, codeSamplesFile));
// Under `vite dev` a document saved half written would fail every page; a build must still fail on it.
const sections = import.meta.env.DEV
? (await Promise.allSettled(loading)).flatMap((result) => {
if (result.status === 'fulfilled') return [result.value];
console.error('[OpenAPI] Leaving its reference pages out of the preview:', result.reason);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two follow-ups on this path:

  1. This console.error goes into the dev server's output, which PortalDevServerService captures and shows only after a crash. Whenever the CLI check passes but bundleSpec throws (see the comment on localFile), nothing tells the user why the reference disappeared. It's worth getting this message to the terminal.
  2. prerenderPages in vite.config.ts still loads every spec with Promise.all when the config loads, under vite dev too. A spec in that state therefore stops the preview from starting ("The portal preview stopped unexpectedly"). I confirmed dev does the same, so it isn't a regression, but "the preview survives a broken specification" only holds for edits made after startup.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. Agreed it's a gap. With the check now matching the bundler on the cases you found, what this leaves silent are the $id case, an unreachable URL, and what only the portal refuses, such as a duplicate operationId. Getting the preview's own error into the terminal means picking lines out of the server's output, so I've left it as a follow-up; it's in the description.
  2. Fixed in 9291028: vite dev no longer builds the prerender page list, so a spec the bundler fails on no longer stops the preview from starting. A new e2e test starts the preview with one (a $ref below an $id); it failed with "stopped unexpectedly" before the fix.

return [];
})
: await Promise.all(loading);
return { files: sections.flatMap((section) => section.files) };
}
5 changes: 3 additions & 2 deletions portal-template/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@ import { remarkImageReferences } from './src/lib/remark-image-references.ts';
import { staticFunctionsBase } from './static-functions-base.ts';
import { staticImages } from './static-images.ts';

export default defineConfig(async () => {
export default defineConfig(async ({ command, isPreview }) => {
const [paths, identity] = await Promise.all([readBuildPaths(), readPortalIdentity()]);
const pages = await prerenderPages(paths, identity.siteUrl);
// `vite dev` prerenders nothing, and listing the pages bundles every spec: one that fails would stop it starting.
const pages = command === 'serve' && !isPreview ? [] : await prerenderPages(paths, identity.siteUrl);
const publicDir: string | false = paths.staticDir ?? false;
const portalProjectDirectory = fileURLToPath(new URL('.', import.meta.url));

Expand Down
18 changes: 7 additions & 11 deletions src/actions/action-result.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
enum ResultType {
Success = 0,
Cancel = 130,
Failure = 1,
Failure = 1
}

export class ActionResult<T = void, E = never> {
Expand All @@ -18,18 +18,18 @@ export class ActionResult<T = void, E = never> {
}

static success<T>(value?: T): ActionResult<T> {
return new ActionResult<T>(ResultType.Success, "Succeeded", value);
return new ActionResult<T>(ResultType.Success, 'Succeeded', value);
}

static failed<T = never, E = never>(message = "Failed", error?: E): ActionResult<T, E> {
static failed<T = never, E = never>(message = 'Failed', error?: E): ActionResult<T, E> {
return new ActionResult<T, E>(ResultType.Failure, message, undefined, error);
}

static cancelled<T = never>(message = "Cancelled"): ActionResult<T> {
static cancelled<T = never>(message = 'Cancelled'): ActionResult<T> {
return new ActionResult(ResultType.Cancel, message);
}

static stopped<T = never>(message = "Stopped"): ActionResult<T> {
static stopped<T = never>(message = 'Stopped'): ActionResult<T> {
return new ActionResult(ResultType.Cancel, message);
}

Expand Down Expand Up @@ -68,11 +68,7 @@ export class ActionResult<T = void, E = never> {
return new ActionResult(this.resultType, this.message);
}

public mapAll<R>(
onSuccess: (value?: T) => R,
onFailure: () => R,
onCancel: () => R
): R {
public mapAll<R>(onSuccess: (value?: T) => R, onFailure: () => R, onCancel: () => R): R {
switch (this.resultType) {
case ResultType.Success:
return onSuccess(this.value);
Expand All @@ -82,4 +78,4 @@ export class ActionResult<T = void, E = never> {
return onCancel();
}
}
}
}
79 changes: 40 additions & 39 deletions src/actions/portal/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@ import { ProjectContext } from '../../types/project-context.js';
import { isSkippedByGlob } from '../../types/portal/content-tree.js';
import { GeneratedPages } from '../../types/portal/generated-pages.js';
import { PortalArtifacts } from '../../types/portal/portal-artifacts.js';
import { PortalSettings, PortalSource } from '../../types/portal/portal-source.js';
import { PortalSettings, PortalSource, WatchedSource } from '../../types/portal/portal-source.js';
import { PreviewConfig } from '../../types/portal/preview-config.js';
import { PreviewContent } from '../../types/portal/preview-content.js';
import { PreviewSpecs } from '../../types/portal/preview-specs.js';
import { FileWatch, FileWatchService } from '../../infrastructure/file-watch-service.js';
import { NetworkService } from '../../infrastructure/network-service.js';
import { LauncherService } from '../../infrastructure/launcher-service.js';
Expand Down Expand Up @@ -78,7 +79,9 @@ export class PortalServeAction {
contentWatch?.recheck();
}
});
const specWatch = this.watchSpecs(project, source);
const closeWatches = async () => {
await specWatch?.close();
await configWatch?.close();
await contentWatch?.close();
};
Expand Down Expand Up @@ -147,25 +150,15 @@ export class PortalServeAction {

const applied = await this.projectService.applyConfig(portalProjectDirectory, settings);
if (applied.isErr()) {
this.prompts.configNotApplied(applied.error);
this.prompts.notApplied('config', applied.error, sourceDirectory);
return;
}
if (preview.show(config, applied.value)) {
this.prompts.configApplied();
}
this.prompts.configShown(preview.show(config, applied.value));
onApplied(settings);
};

return this.startWatch(
(onChange) =>
this.fileWatchService.watch(sourceDirectory, new FileName(APIMATIC_CONFIG_FILE_NAME), onChange, (reason) =>
this.prompts.configWatchFailed(reason)
),
applyEdit,
{
notWatched: (reason) => this.prompts.configNotWatched(reason),
thrown: (reason) => this.prompts.configNotApplied(reason)
}
return this.startWatch('config', sourceDirectory, applyEdit, (onChange, onFailed) =>
this.fileWatchService.watch(sourceDirectory, new FileName(APIMATIC_CONFIG_FILE_NAME), onChange, onFailed)
);
}

Expand Down Expand Up @@ -197,30 +190,37 @@ export class PortalServeAction {
checked.value.files
);
if (applied.isErr()) {
this.prompts.contentNotApplied(applied.error, sourceDirectory);
this.prompts.notApplied('content', applied.error, sourceDirectory);
return;
}
const shown = preview.show(checked.value.notices);
if (shown.fixed) {
this.prompts.contentAccepted(sourceDirectory);
}
this.prompts.contentNotices(shown.notices, sourceDirectory);
this.prompts.contentShown(preview.show(checked.value.notices), sourceDirectory);
};

return this.startWatch(
(onChange) =>
this.fileWatchService.watchTree(
contentDirectory,
onChange,
(reason) => this.prompts.contentWatchFailed(reason, sourceDirectory),
// What the build never reads, as an editor's swap file, would only have the check run again.
isSkippedByGlob
),
check,
{
notWatched: (reason) => this.prompts.contentNotWatched(reason, sourceDirectory),
thrown: (reason) => this.prompts.contentNotChecked(reason, sourceDirectory)
// What the build never reads, as an editor's swap file, would only have the check run again.
return this.startWatch('content', sourceDirectory, check, (onChange, onFailed) =>
this.fileWatchService.watchTree(contentDirectory, onChange, onFailed, isSkippedByGlob)
);
}

/** Held to a build's rules, as `content/` is, but the preview itself leaves out a reference it cannot read. */
private watchSpecs(project: ProjectContext, source: PortalSource): FileWatch | undefined {
const sourceDirectory = project.sourceDirectory();
const sourceContext = project.portalSource();
const preview = new PreviewSpecs(source.specs);

const check = async () => {
const checked = await sourceContext.resolveSpecs();
if (checked.isErr()) {
preview.refuse();
this.prompts.specRejected(checked.error, sourceDirectory);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two noisy cases, both reproducible on Windows:

  • Deleting a referenced file prints the same refusal twice (6 out of 6 runs). Consider not repeating a refusal identical to the last one reported.
  • An editor that saves by deleting and rewriting the file, with a gap longer than the settle time (200 ms in my runs), gets "'src/spec' has no API definition … Move it up, or … set 'MergeConfiguration.MergeApis' …" and then "is fixed" straight after. The folder advice is misleading for a document caught mid-save.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both come from timing, so I've left them and listed them in the description. The repeated refusal is a watch event landing while the check runs, which queues one more check; dropping a refusal identical to the last would also hide the answer to a real second save. For the slow delete-and-rewrite, at that moment the folder really has no definition, and the next save says it's fixed; telling a mid-save from a deletion would mean the watch waiting longer on every save.

return;
}
this.prompts.specsShown(preview.show(checked.value), sourceDirectory);
};

// As for content: an editor's swap file, saved while a document is refused, would only repeat the refusal.
return this.startWatch('spec', sourceDirectory, check, (onChange, onFailed) =>
this.fileWatchService.watchTree(source.specDirectory, onChange, onFailed, isSkippedByGlob)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On Windows, deleting spec/, or renaming it away and back, silently ends this watch. Later saves are never checked, and onFailed never fires, so there's no "no longer watched" warning either.

I reproduced it with this watch, and with FileWatchService.watchTree directly on dev (the content watch has the same gap), so the cause predates this PR. A whole spec folder is just more likely to be replaced than content/. Not tested on Linux/macOS, which use a different code path in watchEachDirectory.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for tracing it to watchTree on dev. Since the content/ watch has the same gap, I'd rather fix it in the file watch for both than here, so I've left it as a follow-up; it's listed in the description.

);
}

Expand All @@ -229,21 +229,22 @@ export class PortalServeAction {
* before the preview started, which can take a minute, and a save made meanwhile reached no watch.
*/
private startWatch(
watch: (onChange: () => Promise<void>) => Result<FileWatch, string>,
watched: WatchedSource,
sourceDirectory: DirectoryPath,
handle: () => Promise<void>,
report: { notWatched: (reason: string) => void; thrown: (reason: string) => void }
watch: (onChange: () => Promise<void>, onFailed: (reason: string) => void) => Result<FileWatch, string>
): FileWatch | undefined {
// The watch drops whatever its handler throws, which would leave the preview stale without a word.
const onChange = async () => {
try {
await handle();
} catch (error) {
report.thrown(errorMessage(error));
this.prompts.notChecked(watched, errorMessage(error), sourceDirectory);
}
};
const started = watch(onChange);
const started = watch(onChange, (reason) => this.prompts.watchFailed(watched, reason, sourceDirectory));
if (started.isErr()) {
report.notWatched(started.error);
this.prompts.notWatched(watched, started.error, sourceDirectory);
return undefined;
}
started.value.recheck();
Expand Down
6 changes: 4 additions & 2 deletions src/actions/quickstart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,8 +280,10 @@ export class QuickstartAction {
if (await this.fileService.isZipFile(specPath)) {
return { supported: true };
}
const document = OpenApiDocument.parse(specPath.name(), await this.fileService.getContents(specPath));
return document === undefined ? { supported: true } : document.format();
return OpenApiDocument.parse(await this.fileService.getContents(specPath)).match(
(document) => document.format(),
(): SpecFormat => ({ supported: true })
);
} catch {
return { supported: true };
}
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 @@ -11,7 +11,7 @@ const defaultPortText = String(DEFAULT_PORTAL_PORT);
export default class PortalServe extends Command {
static readonly summary = 'Preview your API Documentation Portal with live reload.';

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'.
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', edit the documents in 'src/spec', 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 which folders its 'APIMATIC-META.json' merges, or changing the path in 'portal.site.url', needs the preview restarted.

Expand Down
56 changes: 46 additions & 10 deletions src/infrastructure/file-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@ function isDanglingLink(error: unknown): boolean {
return code === 'ENOENT' || code === 'ENOTDIR';
}

interface DirectoryEntries {
fileNames: FileName[];
subDirectories: DirectoryPath[];
}

/** Each folder's entries by its path, read once for the files of one call. */
type Listings = Map<string, Promise<DirectoryEntries>>;

function spelling<T>(name: string, entries: T[], nameOf: (entry: T) => string): T | undefined {
return (
entries.find((entry) => nameOf(entry) === name) ??
Expand Down Expand Up @@ -155,21 +163,43 @@ 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 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);
const [found] = await this.spellingsOnDisk(root, [file]);
return found;
}

/** `directory`, at or below `root`, as each folder below `root` is spelt on disk; null when one is not there. */
/** As `spelledOnDisk` for each of `files`, listing each folder once: a split specification runs to thousands. */
public async spellingsOnDisk(root: DirectoryPath, files: FilePath[]): Promise<(FilePath | null)[]> {
const listings: Listings = new Map();
return await Promise.all(
files.map(async (file) => {
const directory = await this.spelledDirectory(root, file.directory(), listings);
if (directory === null) {
return null;
}
const { fileNames } = await this.listOnce(directory, listings);
const match = spelling(file.name().toString(), fileNames, (candidate) => candidate.toString());
return match === undefined ? null : new FilePath(directory, match);
})
);
}

/** `directory` as each folder below `root` is spelt on disk; null when one is not there, or it is not below `root`. */
public async directorySpelledOnDisk(root: DirectoryPath, directory: DirectoryPath): Promise<DirectoryPath | null> {
return await this.spelledDirectory(root, directory, new Map());
}

private async spelledDirectory(
root: DirectoryPath,
directory: DirectoryPath,
listings: Listings
): Promise<DirectoryPath | null> {
if (!root.contains(directory)) {
return 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 { subDirectories } = await this.listOnce(found, listings);
const match = spelling(name, subDirectories, (subDirectory) => path.basename(subDirectory.toString()));
if (match === undefined) {
return null;
Expand All @@ -179,10 +209,16 @@ export class FileService {
return found;
}

private listOnce(dir: DirectoryPath, listings: Listings): Promise<DirectoryEntries> {
const listing = listings.get(dir.toString()) ?? this.listEntries(dir);
listings.set(dir.toString(), listing);
return listing;
}

// The direct children of `dir`, split into files and directories; both empty when it cannot
// be read. A symlink counts as what it points at: skipping it silently drops an entry the
// user did put there.
private async listEntries(dir: DirectoryPath): Promise<{ fileNames: FileName[]; subDirectories: DirectoryPath[] }> {
private async listEntries(dir: DirectoryPath): Promise<DirectoryEntries> {
const fileNames: FileName[] = [];
const subDirectories: DirectoryPath[] = [];
try {
Expand Down
32 changes: 16 additions & 16 deletions src/infrastructure/launcher-service.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
import { FilePath } from "../types/file/filePath.js";
import { execa } from "execa";
import os from "os";
import { spawn } from "child_process";
import open from "open";
import { UrlPath } from "../types/file/urlPath.js";
import isInCi from "is-in-ci";
import { DirectoryPath } from "../types/file/directoryPath.js";
import { FilePath } from '../types/file/filePath.js';
import { execa } from 'execa';
import os from 'os';
import { spawn } from 'child_process';
import open from 'open';
import { UrlPath } from '../types/file/urlPath.js';
import isInCi from 'is-in-ci';
import { DirectoryPath } from '../types/file/directoryPath.js';

export class LauncherService {
public async openFolderInIde(directoryPath: DirectoryPath, fileToOpen: FilePath): Promise<boolean> {
if (isInCi) return false;
try {
const args = [directoryPath.toString(), fileToOpen.toString()];
await execa("code", args);
await execa('code', args);
return true;
} catch {
return false;
Expand All @@ -36,22 +36,22 @@ export class LauncherService {
let args: string[];

switch (os.platform()) {
case "win32":
command = "cmd";
args = ["/c", "start", "", targetPath];
case 'win32':
command = 'cmd';
args = ['/c', 'start', '', targetPath];
break;
case "darwin":
command = "open";
case 'darwin':
command = 'open';
args = [targetPath];
break;
default:
command = "xdg-open";
command = 'xdg-open';
args = [targetPath];
break;
}

try {
const child = spawn(command, args, { stdio: "ignore", detached: true });
const child = spawn(command, args, { stdio: 'ignore', detached: true });
child.unref(); // Let it run without blocking
} catch {
// Silently ignore errors
Expand Down
Loading
Loading