Skip to content
4 changes: 4 additions & 0 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@
// "auth:status"
// "portal:quickstart",
// "portal:generate",
// "portal:new:toc",
//"--expand-endpoints",
//"--expand-models",
// "--force",
// "--folder",
// "test-source",
// "--destination",
Expand Down
26 changes: 20 additions & 6 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,8 @@
"striptags": "^3.2.0",
"treeify": "^1.1.0",
"tslib": "^2.5.0",
"unzipper": "^0.12.3"
"unzipper": "^0.12.3",
"yaml": "^2.8.0"
},
"devDependencies": {
"@commitlint/cli": "^15.0.0",
Expand Down
2 changes: 1 addition & 1 deletion src/actions/portal/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export class PortalGenerateAction {
const portalGenerationResult = await docsPortalService.generateOnPremPortal(generatePortalParams, configDir);
await deleteFile(sourceBuildInputZipFilePath);

if (portalGenerationResult.isSuccess) {
if (portalGenerationResult.isSuccess()) {
await this.saveGeneratedPortalStreamToZipFile(
portalGenerationResult.value!,
paths.generatedPortalArtifactsZipFilePath
Expand Down
181 changes: 181 additions & 0 deletions src/actions/portal/new/toc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import * as path from "path";
import * as fs from "fs-extra";
import { PortalNewTocPrompts } from "../../../prompts/portal/new/toc";
import { Result } from "../../../types/common/result";
import { getMessageInRedColor } from "../../../utils/utils";
import { SdlParser } from "../../../application/portal/new/toc/sdl-parser";
import { TocStructureGenerator } from "../../../application/portal/new/toc/toc-structure-generator";
import { TocContentParser } from "../../../application/portal/new/toc/toc-content-parser";
import { TocEndpoint, TocGroup, TocModel } from "../../../types/toc/toc";

const DEFAULT_TOC_FILENAME = "toc.yml";
const APIMATIC_BUILD_FILENAME = "APIMATIC-BUILD.json";

export class PortalNewTocAction {
private readonly prompts: PortalNewTocPrompts;
private readonly sdlParser: SdlParser;
private readonly tocGenerator: TocStructureGenerator;
private readonly contentParser: TocContentParser;

constructor() {
this.prompts = new PortalNewTocPrompts();
this.sdlParser = new SdlParser();
this.tocGenerator = new TocStructureGenerator();
this.contentParser = new TocContentParser();
}

async createToc(
workingDirectory: string,
configDir: string,
destination?: string,
force: boolean = false,
expandEndpoints: boolean = false,
expandModels: boolean = false
): Promise<Result<string, string>> {
try {
const tocDir = await this.getDestinationPath(workingDirectory, destination);
const tocPath = path.join(tocDir, DEFAULT_TOC_FILENAME);
const tocCheckResult = await this.handleExistingToc(tocPath, force);
if (!tocCheckResult.isSuccess()) {
return tocCheckResult;
}

const { endpointGroups, models } = await this.extractSdlComponents(
workingDirectory,
configDir,
expandEndpoints,
expandModels
);

const contentGroups = await this.extractContentGroups(workingDirectory);

const toc = this.tocGenerator.createTocStructure(
endpointGroups,
models,
expandEndpoints,
expandModels,
contentGroups
);
const yamlString = this.tocGenerator.transformToYaml(toc);
this.writeToc(tocPath, yamlString, "utf8");

this.prompts.displayOutroMessage(tocPath);
return Result.success(tocPath);
} catch (error) {
this.prompts.logError(getMessageInRedColor(`${error}`));
return Result.failure(`❌ An unexpected error occurred while generating the TOC file.`);
}
}

private async writeToc(path: string, content: string, encoding: string) {
await fs.ensureFile(path);
await fs.writeFile(path, content, encoding);
}

private async handleExistingToc(tocPath: string, force: boolean): Promise<Result<string, string>> {
const shouldContinue = await this.checkExistingToc(tocPath, force);
if (!shouldContinue) {
return Result.cancelled("Operation was cancelled by the user.");
}
return Result.success("TOC check passed.");
}

private async extractSdlComponents(
workingDirectory: string,
configDir: string,
expandEndpoints: boolean,
expandModels: boolean
): Promise<{ endpointGroups: Map<string, TocEndpoint[]>; models: TocModel[] }> {
if (!expandEndpoints && !expandModels) {
return { endpointGroups: new Map(), models: [] };
}

this.prompts.startProgressIndicatorWithMessage("Extracting endpoints and/or models from the API specification...");
const specFolderPath = await this.getSpecFolderPath(workingDirectory);

if (!(await fs.pathExists(specFolderPath))) {
this.prompts.stopProgressIndicatorWithMessage(`⚠️ Could not find the specification folder at: ${specFolderPath}`);
this.prompts.displayInfo("Falling back to default TOC structure without expanded endpoints or models...");
return { endpointGroups: new Map(), models: [] };
}

const sdlResult = await this.sdlParser.getTocComponentsFromSdl(specFolderPath, workingDirectory, configDir);

if (!sdlResult.isSuccess()) {
this.prompts.stopProgressIndicatorWithMessage(`⚠️ ${sdlResult.error!}`);
this.prompts.displayInfo("Falling back to default TOC structure without expanded endpoints or models...");
return { endpointGroups: new Map(), models: [] };
}

this.prompts.stopProgressIndicatorWithMessage("✅ Successfully extracted endpoints and/or models from the specification.");
return sdlResult.value!;

}

private async extractContentGroups(workingDirectory: string): Promise<TocGroup[]> {
const contentFolderPath = await this.getContentFolderPath(workingDirectory);

if (!(await fs.pathExists(contentFolderPath))) {
this.prompts.displayInfo(`⚠️ Could not locate the content folder at: ${contentFolderPath}`);
this.prompts.displayInfo("Skipping custom content addition in TOC...");
return [];
}

return await this.contentParser.parseContentFolder(contentFolderPath, contentFolderPath);
}

private async getDestinationPath(workingDirectory: string, providedDestination?: string): Promise<string> {
if (providedDestination === undefined) {
const inferredDestination = await this.getContentFolderPath(workingDirectory);
return inferredDestination;
}
return providedDestination;
}

private async checkExistingToc(tocPath: string, force: boolean): Promise<boolean> {
if ((await fs.pathExists(tocPath)) && !force) {
return await this.prompts.overwriteExistingTocPrompt();
}
return true;
}

private async getContentFolderPath(workingDirectory: string): Promise<string> {
const buildFilePath = path.join(workingDirectory, APIMATIC_BUILD_FILENAME);
const defaultContentFolder = path.join(workingDirectory, "content");

if (!(await fs.pathExists(buildFilePath))) {
return defaultContentFolder;
}

try {
const buildConfig = await fs.readJson(buildFilePath, "utf8");

if (buildConfig.generatePortal?.contentFolder == null) {
return defaultContentFolder;
}
return path.join(workingDirectory, buildConfig.generatePortal.contentFolder, "content");
} catch {
return defaultContentFolder;
}
}

private async getSpecFolderPath(workingDirectory: string): Promise<string> {
const buildFilePath = path.join(workingDirectory, APIMATIC_BUILD_FILENAME);
const defaultSpecFolder = path.join(workingDirectory, "spec");

if (!(await fs.pathExists(buildFilePath))) {
return defaultSpecFolder;
}

try {
const buildConfig = await fs.readJson(buildFilePath, "utf8");

if (buildConfig.generatePortal?.apiSpecPath == null) {
return defaultSpecFolder;
}
return path.join(workingDirectory, buildConfig.generatePortal.apiSpecPath);
} catch {
return defaultSpecFolder;
}
}
}
66 changes: 66 additions & 0 deletions src/application/portal/new/toc/sdl-parser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import * as path from "path";
import { PortalService } from "../../../../infrastructure/services/portal-service";
import { validateAndZipPortalSource, deleteFile } from "../../../../utils/utils";
import { TocEndpoint, TocModel } from "../../../../types/toc/toc";
import { Result } from "../../../../types/common/result";
import { Sdl, SdlEndpoint, SdlModel } from "../../../../types/sdl/sdl";

export class SdlParser {
private readonly portalService: PortalService;

constructor() {
this.portalService = new PortalService();
}

async getTocComponentsFromSdl(specFolderPath: string, workingDirectory: string, configDir: string): Promise<Result<{endpointGroups: Map<string, TocEndpoint[]>;models: TocModel[];},string>> {
const sourceSpecInputZipFilePath = await validateAndZipPortalSource(
specFolderPath,
path.join(workingDirectory, ".spec_source.zip")
);

try {
const result = await this.portalService.generateSdl(sourceSpecInputZipFilePath, configDir);

if (!result.isSuccess()) {
return Result.failure("Failed to extract endpoints/models from the specification. Please validate your spec using APIMatic’s interactive VS Code extension")
}

const sdl : Sdl = result.value!
const endpointGroups = this.extractEndpointGroups(sdl);
const models = this.extractModels(sdl);

return Result.success({ endpointGroups, models });
} finally {
await deleteFile(sourceSpecInputZipFilePath);
}
}

private extractEndpointGroups(sdl: Sdl): Map<string, TocEndpoint[]> {
const endpointGroups = new Map<string, TocEndpoint[]>();

const endpoints = sdl.Endpoints.map((e: SdlEndpoint): TocEndpoint => ({
generate: null,
from: "endpoint",
endpointName: e.Name,
endpointGroup: e.Group
}));

endpoints.forEach((endpoint: TocEndpoint) => {
const group = endpoint.endpointGroup;
if (!endpointGroups.has(group)) {
endpointGroups.set(group, []);
}
endpointGroups.get(group)!.push(endpoint);
});

return endpointGroups;
}

private extractModels(sdl: Sdl): TocModel[] {
return sdl.CustomTypes.map((e: SdlModel): TocModel => ({
generate: null,
from: "model",
modelName: e.Name
}));
}
}
48 changes: 48 additions & 0 deletions src/application/portal/new/toc/toc-content-parser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import * as path from "path";
import * as fs from "fs-extra";
import { TocGroup, TocCustomPage } from "../../../../types/toc/toc";

export class TocContentParser {
async parseContentFolder(contentFolderPath: string, workingDirectory: string): Promise<TocGroup[]> {
const items = await fs.readdir(contentFolderPath);
const contentItems: (TocGroup | TocCustomPage)[] = [];

for (const item of items) {
const itemPath = path.join(contentFolderPath, item);
const stats = await fs.stat(itemPath);

if (stats.isDirectory()) {
const subItems = await this.parseContentFolder(itemPath, workingDirectory);
if (subItems.length > 0) {
contentItems.push({
group: item,
items: subItems[0].items // Take items from the Custom Content group
});
}
} else if (stats.isFile() && item.endsWith(".md")) {
const relativePath = path.relative(workingDirectory, itemPath);
const pageName = path.basename(item, ".md");

contentItems.push({
page: pageName,
file: this.normalizePath(relativePath)
});
}
}

// Return empty array if no markdown files were found
if (contentItems.length === 0) {
return [];
}

// Wrap everything under a "Custom Content" group
return [{
group: "Custom Content",
items: contentItems
}];
}

private normalizePath(path: string) : string {
return path.replace(/\\/g, '/');
}
}
Loading