import * as plugins from './plugins.js'; import { addHeadingIds, assertRealPathInside, enhanceCodeBlocks, decodeBasicHtml, escapeHtml, isPathInside, normalizeAccentColor, normalizeRepositoryUrl, sectionFromPath, slugify, stripHtml, titleFromPath, toBase64, toPosixRelativePath, } from './helpers.js'; import type { IDocsWebsiteBuildResult, IDocsWebsiteBundledContent, IDocsWebsiteConfig, IDocsWebsiteManifest, IDocsWebsitePage, IDocsWebsiteSection, IDocsWebsiteTypedocSourceConfig, IDocsWebsiteVersion, } from './interfaces.js'; interface IDiscoveredFiles { markdown: string[]; typedoc: string[]; openapi: string[]; assets: string[]; } interface IMarkdownCandidate { absolutePath: string; relativePath: string; html: string; title: string; description?: string; sectionTitle: string; sectionOrder: number; order: number; hidden: boolean; id: string; } interface IPageCandidate { page: IDocsWebsitePage; sectionOrder: number; } interface ITypeDocReflection { id?: number; name?: string; kind?: number; variant?: string; flags?: Record; comment?: ITypeDocComment; children?: ITypeDocReflection[]; signatures?: ITypeDocReflection[]; parameters?: ITypeDocReflection[]; type?: ITypeDocType; typeParameters?: ITypeDocReflection[]; defaultValue?: string; sources?: Array<{ fileName?: string; line?: number }>; } interface ITypeDocComment { summary?: Array<{ text?: string }>; blockTags?: Array<{ tag?: string; content?: Array<{ text?: string }> }>; } interface ITypeDocType { type?: string; name?: string; value?: unknown; operator?: string; targetType?: ITypeDocType; elementType?: ITypeDocType; objectType?: ITypeDocType; indexType?: ITypeDocType; checkType?: ITypeDocType; extendsType?: ITypeDocType; trueType?: ITypeDocType; falseType?: ITypeDocType; types?: ITypeDocType[]; elements?: ITypeDocType[]; typeArguments?: ITypeDocType[]; declaration?: ITypeDocReflection; } interface ITypeDocProject extends ITypeDocReflection { schemaVersion?: string; } interface IOpenApiDocument { openapi?: string; swagger?: string; info?: { title?: string; version?: string; description?: string; }; servers?: Array<{ url?: string; description?: string }>; host?: string; basePath?: string; schemes?: string[]; paths?: Record>; components?: { schemas?: Record; parameters?: Record; requestBodies?: Record; responses?: Record }; definitions?: Record; parameters?: Record; responses?: Record; } interface IOpenApiFormatState { depth: number; activeRefs: Set; activeSchemas: Set>; } const skippedDirectories = new Set([ '.git', '.nogit', 'build', 'coverage', 'node_modules', 'public', ]); const assetExtensions = new Set([ '.avif', '.gif', '.ico', '.jpeg', '.jpg', '.png', '.svg', '.ttf', '.webp', '.woff', '.woff2', ]); const openApiMethods = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'trace'] as const; const isRecord = (valueArg: unknown): valueArg is Record => { return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg); }; const numberOr = (valueArg: unknown, fallbackArg: number): number => { return typeof valueArg === 'number' && Number.isFinite(valueArg) ? valueArg : fallbackArg; }; const stringOrUndefined = (valueArg: unknown): string | undefined => { return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined; }; const normalizeVersionHref = (valueArg: unknown): string | undefined => { const value = stringOrUndefined(valueArg); if (!value) return undefined; if (value.startsWith('#/')) return value; if (value.startsWith('/') && !value.startsWith('//') && !value.startsWith('/\\')) { return value; } try { const parsed = new URL(value); return parsed.protocol === 'https:' || parsed.protocol === 'http:' ? parsed.toString() : undefined; } catch { return undefined; } }; const getCommentText = (reflectionArg: ITypeDocReflection): string => { const summary = reflectionArg.comment?.summary?.map(part => part.text ?? '').join('').trim() ?? ''; const tags = reflectionArg.comment?.blockTags ?.map(tag => `${tag.tag ?? ''} ${tag.content?.map(part => part.text ?? '').join('') ?? ''}`.trim()) .filter(Boolean) .join('\n') ?? ''; return [summary, tags].filter(Boolean).join('\n\n'); }; const formatTypeDocType = (typeArg: ITypeDocType | undefined): string => { if (!typeArg) return 'unknown'; switch (typeArg.type) { case 'intrinsic': case 'reference': { const argumentsText = typeArg.typeArguments?.length ? `<${typeArg.typeArguments.map(formatTypeDocType).join(', ')}>` : ''; return `${typeArg.name ?? 'unknown'}${argumentsText}`; } case 'array': return `${formatTypeDocType(typeArg.elementType)}[]`; case 'union': return typeArg.types?.map(formatTypeDocType).join(' | ') || 'unknown'; case 'intersection': return typeArg.types?.map(formatTypeDocType).join(' & ') || 'unknown'; case 'tuple': return `[${typeArg.elements?.map(formatTypeDocType).join(', ') ?? ''}]`; case 'literal': return typeof typeArg.value === 'string' ? JSON.stringify(typeArg.value) : String(typeArg.value); case 'typeOperator': return `${typeArg.operator ?? ''} ${formatTypeDocType(typeArg.targetType)}`.trim(); case 'indexedAccess': return `${formatTypeDocType(typeArg.objectType)}[${formatTypeDocType(typeArg.indexType)}]`; case 'conditional': return `${formatTypeDocType(typeArg.checkType)} extends ${formatTypeDocType(typeArg.extendsType)} ? ${formatTypeDocType(typeArg.trueType)} : ${formatTypeDocType(typeArg.falseType)}`; case 'reflection': { const children = typeArg.declaration?.children ?? []; if (!children.length) return 'object'; return `{ ${children.map(child => `${child.name ?? 'value'}${child.flags?.isOptional ? '?' : ''}: ${formatTypeDocType(child.type)}`).join('; ')} }`; } default: return typeArg.name ?? typeArg.type ?? 'unknown'; } }; const kindTitle = (kindArg: number | undefined): string => { if (!kindArg) return 'Declaration'; try { const enumLabel = plugins.typedoc.ReflectionKind[kindArg]; return String(enumLabel).replace(/([a-z])([A-Z])/g, '$1 $2'); } catch { return 'Declaration'; } }; const memberGroupTitle = (reflectionArg: ITypeDocReflection): string => { switch (reflectionArg.kind) { case plugins.typedoc.ReflectionKind.Constructor: return 'Constructors'; case plugins.typedoc.ReflectionKind.Method: case plugins.typedoc.ReflectionKind.Function: return 'Methods'; case plugins.typedoc.ReflectionKind.Property: case plugins.typedoc.ReflectionKind.Variable: return 'Properties'; case plugins.typedoc.ReflectionKind.Accessor: return 'Accessors'; default: return 'Members'; } }; const formatSignature = (signatureArg: ITypeDocReflection, fallbackNameArg: string): string => { const typeParameters = signatureArg.typeParameters?.length ? `<${signatureArg.typeParameters.map(parameter => parameter.name ?? 'T').join(', ')}>` : ''; const parameters = signatureArg.parameters?.map(parameter => { const optional = parameter.flags?.isOptional ? '?' : ''; return `${parameter.name ?? 'value'}${optional}: ${formatTypeDocType(parameter.type)}`; }).join(', ') ?? ''; return `${signatureArg.name ?? fallbackNameArg}${typeParameters}(${parameters}): ${formatTypeDocType(signatureArg.type)}`; }; const renderParagraphs = (valueArg: unknown): string => { const value = stringOrUndefined(valueArg); if (!value) return ''; return value.split(/\n{2,}/).map(paragraph => `

${escapeHtml(paragraph).replaceAll('\n', '
')}

`).join(''); }; export class DocsWebsiteBuilder { private readonly docsDir: string; private rootRealPath = ''; private usedPageIds = new Map(); constructor(docsDirArg: string) { this.docsDir = plugins.path.resolve(docsDirArg); } public async build(): Promise { this.rootRealPath = await plugins.fs.realpath(this.docsDir); this.usedPageIds.clear(); const [packageJson, config, discovered] = await Promise.all([ this.readPackageJson(), this.readWebsiteConfig(), this.discoverFiles(), ]); const assetMap = new Map(); const assetContent: IDocsWebsiteBundledContent[] = []; for (const absolutePath of discovered.assets) { const relativePath = toPosixRelativePath(this.rootRealPath, absolutePath); const servePath = `docs-assets/${relativePath}`; assetMap.set(relativePath, `/${servePath}`); assetContent.push({ path: servePath, contentBase64: toBase64(await plugins.fs.readFile(absolutePath)), }); } const markdownCandidates = await this.prepareMarkdownCandidates(discovered.markdown); const markdownRouteMap = new Map(); for (const candidate of markdownCandidates.filter(candidate => !candidate.hidden)) { const sectionId = slugify(candidate.sectionTitle); markdownRouteMap.set(candidate.relativePath, { route: `#/${sectionId}/${candidate.id}`, pageId: candidate.id, }); } const pageCandidates: IPageCandidate[] = []; pageCandidates.push(...this.buildMarkdownPages(markdownCandidates, markdownRouteMap, assetMap)); for (const absolutePath of discovered.typedoc) { const project = await this.readTypeDocProject(absolutePath); pageCandidates.push(...this.buildTypeDocPages( project, toPosixRelativePath(this.rootRealPath, absolutePath), 'API Reference', 1000, )); } for (const typedocConfig of config.typedoc ?? []) { const project = await this.buildConfiguredTypeDocProject(typedocConfig); pageCandidates.push(...this.buildTypeDocPages( project, `typedoc:${typedocConfig.title ?? typedocConfig.entryPoints.join(',')}`, typedocConfig.section ?? 'API Reference', numberOr(typedocConfig.order, 1000), )); } for (const absolutePath of discovered.openapi) { const document = await this.readOpenApiDocument(absolutePath); pageCandidates.push(this.buildOpenApiPage( document, toPosixRelativePath(this.rootRealPath, absolutePath), )); } const pages = pageCandidates.map(candidate => candidate.page); const sections = this.buildSections(pageCandidates); const packageRepository = packageJson.repository; const repositoryValue = config.repositoryUrl ?? (typeof packageRepository === 'string' ? packageRepository : isRecord(packageRepository) ? stringOrUndefined(packageRepository.url) : undefined); const versionLabel = config.version ?? stringOrUndefined(packageJson.version) ?? 'current'; const versions = this.normalizeVersions(config.versions, versionLabel); const manifest: IDocsWebsiteManifest = { schemaVersion: '1.0', title: config.title ?? this.titleFromPackage(packageJson.name), badge: config.badge ?? 'Developer', version: versionLabel, accentColor: normalizeAccentColor(config.accentColor), repositoryUrl: normalizeRepositoryUrl(repositoryValue), versions, sections, pages, }; const { files: bundledWebsiteFiles } = await import('../bundled_website.js'); const manifestContent: IDocsWebsiteBundledContent = { path: 'docs-manifest.json', contentBase64: toBase64(`${JSON.stringify(manifest)}\n`), }; return { manifest, bundledContent: [ ...bundledWebsiteFiles, manifestContent, ...assetContent, ], }; } private async readPackageJson(): Promise> { const packagePath = plugins.path.join(this.rootRealPath, 'package.json'); try { return JSON.parse(await plugins.fs.readFile(packagePath, 'utf8')) as Record; } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return {}; throw new Error(`Could not read package.json: ${error instanceof Error ? error.message : String(error)}`); } } private async readWebsiteConfig(): Promise { const configPath = plugins.path.join(this.rootRealPath, '.smartconfig.json'); try { const parsed = JSON.parse(await plugins.fs.readFile(configPath, 'utf8')) as Record; const legacyScope = isRecord(parsed.tsdoc) ? parsed.tsdoc : {}; const modernScope = isRecord(parsed['@git.zone/tsdoc']) ? parsed['@git.zone/tsdoc'] : {}; const legacy = isRecord(legacyScope.website) ? legacyScope.website : {}; const modern = isRecord(modernScope.website) ? modernScope.website : {}; const merged = { ...legacy, ...modern }; const typedoc = Array.isArray(merged.typedoc) ? merged.typedoc.flatMap((item: unknown): IDocsWebsiteTypedocSourceConfig[] => { if (!isRecord(item) || !Array.isArray(item.entryPoints)) return []; const entryPoints = item.entryPoints.filter((entryPoint: unknown): entryPoint is string => { return typeof entryPoint === 'string' && Boolean(entryPoint.trim()); }); if (entryPoints.length === 0) return []; return [{ entryPoints, tsconfig: stringOrUndefined(item.tsconfig), title: stringOrUndefined(item.title), section: stringOrUndefined(item.section), order: typeof item.order === 'number' && Number.isFinite(item.order) ? item.order : undefined, }]; }) : undefined; const versions = Array.isArray(merged.versions) ? merged.versions.flatMap((item: unknown) => { if (!isRecord(item)) return []; const label = stringOrUndefined(item.label); if (!label) return []; return [{ label, href: normalizeVersionHref(item.href), current: item.current === true, latest: item.latest === true, legacy: item.legacy === true, }]; }) : undefined; return { title: stringOrUndefined(merged.title), badge: stringOrUndefined(merged.badge), version: stringOrUndefined(merged.version), accentColor: stringOrUndefined(merged.accentColor), repositoryUrl: normalizeRepositoryUrl(merged.repositoryUrl), versions, typedoc, }; } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return {}; throw new Error(`Could not parse .smartconfig.json: ${error instanceof Error ? error.message : String(error)}`); } } private normalizeVersions( versionsArg: IDocsWebsiteConfig['versions'], fallbackLabelArg: string, ): IDocsWebsiteVersion[] { if (!versionsArg?.length) { return [{ label: fallbackLabelArg, current: true, latest: true, legacy: false, }]; } const currentIndex = Math.max(0, versionsArg.findIndex(version => version.current)); const configuredLatestIndex = versionsArg.findIndex(version => version.latest); const latestIndex = configuredLatestIndex >= 0 ? configuredLatestIndex : currentIndex; return versionsArg.map((version, index) => ({ label: version.label, ...(version.href ? { href: version.href } : {}), current: index === currentIndex, latest: index === latestIndex, legacy: version.legacy === true, })); } private titleFromPackage(packageNameArg: unknown): string { const packageName = stringOrUndefined(packageNameArg); if (!packageName) return titleFromPath(plugins.path.basename(this.rootRealPath)); const parts = packageName.replace(/^@/, '').split('/'); const value = parts[0] === 'docs' && parts[1] ? parts[1] : parts[0]; return value.replace(/[._-]+/g, ' ').replace(/\b\w/g, character => character.toUpperCase()); } private async discoverFiles(): Promise { const result: IDiscoveredFiles = { markdown: [], typedoc: [], openapi: [], assets: [] }; const walk = async (directoryArg: string): Promise => { const entries = await plugins.fs.readdir(directoryArg, { withFileTypes: true }); entries.sort((a, b) => a.name.localeCompare(b.name)); for (const entry of entries) { if (entry.isSymbolicLink()) continue; const absolutePath = plugins.path.join(directoryArg, entry.name); if (!isPathInside(this.rootRealPath, absolutePath)) continue; if (entry.isDirectory()) { if (entry.name.startsWith('.') || skippedDirectories.has(entry.name) || entry.name.startsWith('dist')) continue; await walk(absolutePath); continue; } if (!entry.isFile() || entry.name.startsWith('.')) continue; const relativePath = toPosixRelativePath(this.rootRealPath, absolutePath); const lowerName = entry.name.toLowerCase(); if (lowerName.endsWith('.md')) { result.markdown.push(absolutePath); } else if (lowerName.endsWith('.typedoc.json')) { result.typedoc.push(absolutePath); } else if (/^(?:openapi|swagger)\.(?:json|ya?ml)$/i.test(lowerName) || /\.(?:openapi|swagger)\.(?:json|ya?ml)$/i.test(lowerName)) { result.openapi.push(absolutePath); } else if (assetExtensions.has(plugins.path.extname(relativePath).toLowerCase())) { result.assets.push(absolutePath); } } }; await walk(this.rootRealPath); return result; } private async prepareMarkdownCandidates(filesArg: string[]): Promise { const candidates: IMarkdownCandidate[] = []; for (const absolutePath of filesArg) { const relativePath = toPosixRelativePath(this.rootRealPath, absolutePath); const markdown = await plugins.fs.readFile(absolutePath, 'utf8'); const parsed = await new plugins.smartmarkdown.SmartMarkdown().getMdParsedResultFromMarkdown(markdown); const frontmatter = parsed.frontmatterData; const sourceTitle = markdown.match(/^\s*#\s+(.+)$/m)?.[1]?.replace(/[*_`]/g, '').trim(); const requestedId = slugify(relativePath.replace(/\.md$/i, '')); const id = this.uniquePageId(requestedId); candidates.push({ absolutePath, relativePath, html: parsed.html.replace(/]*>[\s\S]*?<\/h1>/i, ''), title: stringOrUndefined(frontmatter.title) ?? sourceTitle ?? titleFromPath(relativePath), description: stringOrUndefined(frontmatter.description), sectionTitle: stringOrUndefined(frontmatter.section) ?? sectionFromPath(relativePath), sectionOrder: numberOr(frontmatter.sectionOrder, 100), order: numberOr(frontmatter.order, 100), hidden: frontmatter.hidden === true, id, }); } return candidates; } private buildMarkdownPages( candidatesArg: IMarkdownCandidate[], routeMapArg: Map, assetMapArg: Map, ): IPageCandidate[] { const pages: IPageCandidate[] = []; for (const candidate of candidatesArg) { if (candidate.hidden) continue; const sectionId = slugify(candidate.sectionTitle); let html = this.rewriteMarkdownUrls(candidate.html, candidate, routeMapArg, assetMapArg); html = enhanceCodeBlocks(html); const withHeadings = addHeadingIds(html, candidate.id); pages.push({ sectionOrder: candidate.sectionOrder, page: { id: candidate.id, title: candidate.title, description: candidate.description, sectionId, sectionTitle: candidate.sectionTitle, order: candidate.order, route: `#/${sectionId}/${candidate.id}`, sourceType: 'markdown', sourcePath: candidate.relativePath, html: withHeadings.html, plainText: stripHtml(withHeadings.html), headings: withHeadings.headings, }, }); } return pages; } private rewriteMarkdownUrls( htmlArg: string, candidateArg: IMarkdownCandidate, routeMapArg: Map, assetMapArg: Map, ): string { const rewriteLink = (rawTargetArg: string): string => { if (/^(?:[a-z]+:|\/\/|\/)/i.test(rawTargetArg)) return rawTargetArg; const [rawPath, rawAnchor] = rawTargetArg.split('#', 2); if (!rawPath) { return rawAnchor ? `${routeMapArg.get(candidateArg.relativePath)?.route ?? '#'}\/${candidateArg.id}-${slugify(rawAnchor)}` : rawTargetArg; } const normalized = plugins.path.posix.normalize(plugins.path.posix.join( plugins.path.posix.dirname(candidateArg.relativePath), rawPath, )); if (normalized === '..' || normalized.startsWith('../')) return rawTargetArg; const route = routeMapArg.get(normalized) ?? routeMapArg.get(`${normalized}.md`) ?? routeMapArg.get(plugins.path.posix.join(normalized, 'readme.md')) ?? routeMapArg.get(plugins.path.posix.join(normalized, 'index.md')); if (!route) return rawTargetArg; return rawAnchor ? `${route.route}/${route.pageId}-${slugify(rawAnchor)}` : route.route; }; const rewriteAsset = (rawTargetArg: string): string => { if (/^(?:[a-z]+:|\/\/|\/|data:)/i.test(rawTargetArg)) return rawTargetArg; const normalized = plugins.path.posix.normalize(plugins.path.posix.join( plugins.path.posix.dirname(candidateArg.relativePath), rawTargetArg.split('#', 1)[0], )); if (normalized === '..' || normalized.startsWith('../')) return rawTargetArg; return assetMapArg.get(normalized) ?? rawTargetArg; }; return htmlArg .replace(/(]*\shref=")([^"]+)(")/gi, (_match, start, target, end) => `${start}${escapeHtml(rewriteLink(decodeBasicHtml(target)))}${end}`) .replace(/(]*\ssrc=")([^"]+)(")/gi, (_match, start, target, end) => `${start}${escapeHtml(rewriteAsset(decodeBasicHtml(target)))}${end}`); } private async readTypeDocProject(absolutePathArg: string): Promise { const parsed = JSON.parse(await plugins.fs.readFile(absolutePathArg, 'utf8')) as ITypeDocProject; if (parsed.variant !== 'project' || !parsed.schemaVersion?.startsWith('2.')) { throw new Error(`Unsupported TypeDoc JSON in ${toPosixRelativePath(this.rootRealPath, absolutePathArg)}. Expected schemaVersion 2.x and variant project.`); } return parsed; } private async buildConfiguredTypeDocProject(configArg: IDocsWebsiteTypedocSourceConfig): Promise { const entryPoints: string[] = []; for (const entryPoint of configArg.entryPoints) { const resolved = plugins.path.resolve(this.rootRealPath, entryPoint); entryPoints.push(await assertRealPathInside(this.rootRealPath, resolved, 'TypeDoc entry point')); } let tsconfig: string | undefined; if (configArg.tsconfig) { tsconfig = await assertRealPathInside( this.rootRealPath, plugins.path.resolve(this.rootRealPath, configArg.tsconfig), 'TypeDoc tsconfig', ); } const application = await plugins.typedoc.Application.bootstrap({ entryPoints, tsconfig, name: configArg.title, }, []); const project = await application.convert(); if (!project) throw new Error(`TypeDoc conversion failed for ${entryPoints.join(', ')}`); application.validate(project); return application.serializer.projectToObject( project, plugins.typedoc.normalizePath(this.rootRealPath), ) as ITypeDocProject; } private buildTypeDocPages( projectArg: ITypeDocProject, sourcePathArg: string, sectionTitleArg: string, baseOrderArg: number, ): IPageCandidate[] { const sectionId = slugify(sectionTitleArg); const roots = projectArg.children?.length ? projectArg.children : [projectArg]; return roots.map((reflection, index) => { const title = reflection.name ?? projectArg.name ?? 'API'; const id = this.uniquePageId(`api-${slugify(projectArg.name ?? 'project')}-${slugify(title)}`); const rawHtml = this.renderTypeDocReflection(reflection); const withHeadings = addHeadingIds(enhanceCodeBlocks(rawHtml), id); return { sectionOrder: baseOrderArg, page: { id, title, description: getCommentText(reflection) || undefined, sectionId, sectionTitle: sectionTitleArg, order: baseOrderArg + index, route: `#/${sectionId}/${id}`, sourceType: 'typedoc' as const, sourcePath: sourcePathArg, html: withHeadings.html, plainText: stripHtml(withHeadings.html), headings: withHeadings.headings, }, }; }); } private renderTypeDocReflection(reflectionArg: ITypeDocReflection): string { const parts: string[] = []; parts.push(`
${escapeHtml(kindTitle(reflectionArg.kind))}
`); parts.push(renderParagraphs(getCommentText(reflectionArg))); if (reflectionArg.type && !reflectionArg.signatures?.length) { parts.push(`
${escapeHtml(reflectionArg.name ?? 'value')}: ${escapeHtml(formatTypeDocType(reflectionArg.type))}
`); } parts.push(this.renderTypeDocSignatures(reflectionArg.signatures ?? [], reflectionArg.name ?? 'call')); const grouped = new Map(); for (const child of reflectionArg.children ?? []) { const title = memberGroupTitle(child); const current = grouped.get(title) ?? []; current.push(child); grouped.set(title, current); } for (const [groupTitle, members] of grouped) { parts.push(`

${escapeHtml(groupTitle)}

`); for (const member of members) { parts.push(`

${escapeHtml(member.name ?? 'member')}

`); parts.push(renderParagraphs(getCommentText(member))); if (member.type && !member.signatures?.length) { parts.push(`
${escapeHtml(member.name ?? 'member')}: ${escapeHtml(formatTypeDocType(member.type))}
`); } if (member.defaultValue) parts.push(`

Default: ${escapeHtml(member.defaultValue)}

`); parts.push(this.renderTypeDocSignatures(member.signatures ?? [], member.name ?? 'call')); parts.push('
'); } } return parts.join(''); } private renderTypeDocSignatures(signaturesArg: ITypeDocReflection[], fallbackNameArg: string): string { return signaturesArg.map(signature => { const parts = [`
${escapeHtml(formatSignature(signature, fallbackNameArg))}
`]; parts.push(renderParagraphs(getCommentText(signature))); const parameters = signature.parameters ?? []; if (parameters.length) { parts.push('
'); for (const parameter of parameters) { const optional = parameter.flags?.isOptional ? 'Optional' : 'Required'; parts.push(`
${escapeHtml(parameter.name ?? 'value')}${escapeHtml(formatTypeDocType(parameter.type))}${optional}
${renderParagraphs(getCommentText(parameter))}
`); } parts.push('
'); } return parts.join(''); }).join(''); } private async readOpenApiDocument(absolutePathArg: string): Promise { const contents = await plugins.fs.readFile(absolutePathArg, 'utf8'); const parsed = absolutePathArg.toLowerCase().endsWith('.json') ? JSON.parse(contents) : await plugins.smartyaml.yamlStringToObject(contents); if (!isRecord(parsed) || (typeof parsed.openapi !== 'string' && typeof parsed.swagger !== 'string') || !isRecord(parsed.paths)) { throw new Error(`Unsupported OpenAPI document in ${toPosixRelativePath(this.rootRealPath, absolutePathArg)}.`); } return parsed as IOpenApiDocument; } private buildOpenApiPage(documentArg: IOpenApiDocument, sourcePathArg: string): IPageCandidate { const title = documentArg.info?.title ?? titleFromPath(sourcePathArg); const sectionTitle = 'HTTP API'; const sectionId = slugify(sectionTitle); const id = this.uniquePageId(`openapi-${slugify(title)}`); const parts: string[] = []; if (documentArg.info?.version) parts.push(`
Version ${escapeHtml(documentArg.info.version)}
`); parts.push(renderParagraphs(documentArg.info?.description)); const servers = documentArg.servers?.map(server => server.url).filter(Boolean) ?? []; if (!servers.length && documentArg.host) { const scheme = documentArg.schemes?.[0] ?? 'https'; servers.push(`${scheme}://${documentArg.host}${documentArg.basePath ?? ''}`); } if (servers.length) { parts.push(`
${servers.map(escapeHtml).join('
')}
`); } for (const [pathName, rawPathItem] of Object.entries(documentArg.paths ?? {})) { const pathItem = this.resolveOpenApiValue(rawPathItem, documentArg); if (!isRecord(pathItem)) continue; for (const method of openApiMethods) { const operation = this.resolveOpenApiValue(pathItem[method], documentArg); if (!isRecord(operation)) continue; parts.push(`

${method.toUpperCase()} ${escapeHtml(pathName)}

`); parts.push(renderParagraphs(operation.summary)); parts.push(renderParagraphs(operation.description)); const parameters = [ ...(Array.isArray(pathItem.parameters) ? pathItem.parameters : []), ...(Array.isArray(operation.parameters) ? operation.parameters : []), ].map(parameter => this.resolveOpenApiValue(parameter, documentArg)).filter(isRecord); if (parameters.length) { parts.push('

Parameters

'); for (const parameter of parameters) { const schema = this.resolveOpenApiValue(parameter.schema, documentArg); const type = this.formatOpenApiSchema(isRecord(schema) ? schema : parameter, documentArg); const required = parameter.required === true ? 'Required' : 'Optional'; parts.push(`
${escapeHtml(parameter.name ?? 'body')}${escapeHtml(type)}${required}
${renderParagraphs(parameter.description)}
`); } parts.push('
'); } const requestBody = this.resolveOpenApiValue(operation.requestBody, documentArg); const swaggerBody = parameters.find(parameter => parameter.in === 'body'); if (isRecord(requestBody) || swaggerBody) { parts.push('

Request body

'); if (isRecord(requestBody)) { parts.push(renderParagraphs(requestBody.description)); const content = isRecord(requestBody.content) ? requestBody.content : {}; for (const [contentType, rawMedia] of Object.entries(content)) { const media = this.resolveOpenApiValue(rawMedia, documentArg); const schema = isRecord(media) ? this.resolveOpenApiValue(media.schema, documentArg) : undefined; parts.push(`
${escapeHtml(contentType)}: ${escapeHtml(this.formatOpenApiSchema(schema, documentArg))}
`); } } else if (swaggerBody) { parts.push(`
${escapeHtml(this.formatOpenApiSchema(swaggerBody.schema, documentArg))}
`); } } const responses = isRecord(operation.responses) ? operation.responses : {}; if (Object.keys(responses).length) { parts.push('

Responses

'); for (const [status, rawResponse] of Object.entries(responses)) { const response = this.resolveOpenApiValue(rawResponse, documentArg); const firstResponseMedia = isRecord(response) && isRecord(response.content) ? Object.values(response.content)[0] : undefined; const schema = isRecord(response) ? response.schema ?? (isRecord(firstResponseMedia) ? firstResponseMedia.schema : undefined) : undefined; parts.push(``); } parts.push('
StatusDescriptionSchema
${escapeHtml(status)}${escapeHtml(isRecord(response) ? response.description ?? '' : '')}${escapeHtml(this.formatOpenApiSchema(schema, documentArg))}
'); } parts.push('
'); } } const schemas = documentArg.components?.schemas ?? documentArg.definitions ?? {}; if (Object.keys(schemas).length) { parts.push('

Schemas

'); for (const [schemaName, rawSchema] of Object.entries(schemas)) { const schema = this.resolveOpenApiValue(rawSchema, documentArg); if (!isRecord(schema)) continue; parts.push(`

${escapeHtml(schemaName)}

${renderParagraphs(schema.description)}`); const required = new Set(Array.isArray(schema.required) ? schema.required.filter((value): value is string => typeof value === 'string') : []); const properties = isRecord(schema.properties) ? schema.properties : {}; if (Object.keys(properties).length) { parts.push('
'); for (const [propertyName, rawProperty] of Object.entries(properties)) { const property = this.resolveOpenApiValue(rawProperty, documentArg); const requiredPill = required.has(propertyName) ? 'Required' : 'Optional'; parts.push(`
${escapeHtml(propertyName)}${escapeHtml(this.formatOpenApiSchema(property, documentArg))}${requiredPill}
${renderParagraphs(isRecord(property) ? property.description : undefined)}
`); } parts.push('
'); } parts.push('
'); } } const withHeadings = addHeadingIds(parts.join(''), id); return { sectionOrder: 2000, page: { id, title, description: documentArg.info?.description, sectionId, sectionTitle, order: 2000, route: `#/${sectionId}/${id}`, sourceType: 'openapi', sourcePath: sourcePathArg, html: withHeadings.html, plainText: stripHtml(withHeadings.html), headings: withHeadings.headings, }, }; } private resolveOpenApiValue(valueArg: unknown, documentArg: IOpenApiDocument, seenArg = new Set()): unknown { if (!isRecord(valueArg) || typeof valueArg.$ref !== 'string' || !valueArg.$ref.startsWith('#/')) return valueArg; if (seenArg.has(valueArg.$ref)) return valueArg; seenArg.add(valueArg.$ref); const segments = valueArg.$ref.slice(2).split('/').map(segment => segment.replaceAll('~1', '/').replaceAll('~0', '~')); let current: unknown = documentArg; for (const segment of segments) { if (!isRecord(current) || !(segment in current)) return valueArg; current = current[segment]; } return this.resolveOpenApiValue(current, documentArg, seenArg); } private formatOpenApiSchema( schemaArg: unknown, documentArg: IOpenApiDocument, stateArg: IOpenApiFormatState = { depth: 0, activeRefs: new Set(), activeSchemas: new Set>(), }, ): string { if (!isRecord(schemaArg)) return 'unknown'; if (stateArg.depth >= 32) return '…'; if (stateArg.activeSchemas.has(schemaArg)) return 'recursive'; const nestedState: IOpenApiFormatState = { depth: stateArg.depth + 1, activeRefs: stateArg.activeRefs, activeSchemas: new Set(stateArg.activeSchemas).add(schemaArg), }; if (typeof schemaArg.$ref === 'string') { const refName = schemaArg.$ref.split('/').pop() ?? 'object'; if (nestedState.activeRefs.has(schemaArg.$ref)) return `${refName} (recursive)`; const resolved = this.resolveOpenApiValue(schemaArg, documentArg); if (resolved !== schemaArg) { return `${refName} (${this.formatOpenApiSchema(resolved, documentArg, { ...nestedState, activeRefs: new Set(nestedState.activeRefs).add(schemaArg.$ref), })})`; } return schemaArg.$ref; } if (Array.isArray(schemaArg.oneOf)) return schemaArg.oneOf.map(value => this.formatOpenApiSchema(value, documentArg, nestedState)).join(' | '); if (Array.isArray(schemaArg.anyOf)) return schemaArg.anyOf.map(value => this.formatOpenApiSchema(value, documentArg, nestedState)).join(' | '); if (Array.isArray(schemaArg.allOf)) return schemaArg.allOf.map(value => this.formatOpenApiSchema(value, documentArg, nestedState)).join(' & '); if (schemaArg.type === 'array') return `${this.formatOpenApiSchema(schemaArg.items, documentArg, nestedState)}[]`; if (Array.isArray(schemaArg.enum)) return schemaArg.enum.map((value: unknown) => JSON.stringify(value)).join(' | '); const format = stringOrUndefined(schemaArg.format); if (format) return `${stringOrUndefined(schemaArg.type) ?? 'string'} (${format})`; return stringOrUndefined(schemaArg.type) ?? 'object'; } private buildSections(candidatesArg: IPageCandidate[]): IDocsWebsiteSection[] { const sectionMap = new Map(); const sortedCandidates = [...candidatesArg].sort((a, b) => { return a.sectionOrder - b.sectionOrder || a.page.sectionTitle.localeCompare(b.page.sectionTitle) || a.page.order - b.page.order || a.page.sourcePath.localeCompare(b.page.sourcePath); }); for (const candidate of sortedCandidates) { let section = sectionMap.get(candidate.page.sectionId); if (!section) { section = { id: candidate.page.sectionId, title: candidate.page.sectionTitle, order: candidate.sectionOrder, pageIds: [], }; sectionMap.set(section.id, section); } section.pageIds.push(candidate.page.id); } return [...sectionMap.values()].sort((a, b) => a.order - b.order || a.title.localeCompare(b.title)); } private uniquePageId(requestedIdArg: string): string { const requestedId = slugify(requestedIdArg); const count = this.usedPageIds.get(requestedId) ?? 0; this.usedPageIds.set(requestedId, count + 1); return count === 0 ? requestedId : `${requestedId}-${count + 1}`; } }