import { readFile } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const INTEGRATIONS_DIR = dirname(fileURLToPath(import.meta.url)); type SemVer = `${number}.${number}.${number}`; const DOC_DIRECTORY_ALIASES = { graphqlintegration: "graphql", } as const; interface DocumentationOverlay { file: string; versionRange?: string; /** When set, checks against the sdk-api version ("javascriptsdkapi") running * in the orchestrator instead of the plugin-specific version. Use this to * gate overlays on sdk-api features that ship independently of plugin * version bumps. Both versionRange and sdkVersionRange can be specified * together — the overlay is applied only when all specified ranges match. */ sdkVersionRange?: string; } interface DocumentationManifest { base?: string; overlays: DocumentationOverlay[]; } type OverlayOperation = | { type: "append"; content: string; } | { type: "replace"; sectionTitle: string; content: string; }; export interface ResolveIntegrationDocumentationOptions { pluginVersion?: SemVer; sdkVersion?: SemVer; integrationsDirectory?: string; } function hasDocDirectoryAlias( pluginId: string, ): pluginId is keyof typeof DOC_DIRECTORY_ALIASES { return pluginId in DOC_DIRECTORY_ALIASES; } function getDocDirectoryForPlugin(pluginId: string): string { if (hasDocDirectoryAlias(pluginId)) { return DOC_DIRECTORY_ALIASES[pluginId]; } return pluginId; } async function parseDocumentationManifest( manifestPath: string, ): Promise { let raw: string; try { raw = await readFile(manifestPath, "utf8"); } catch (error) { if ( error instanceof Error && "code" in error && (error as NodeJS.ErrnoException).code === "ENOENT" ) { return null; } throw error; } const parsed = JSON.parse(raw) as { base?: unknown; overlays?: unknown; }; if (!Array.isArray(parsed.overlays)) { throw new Error( `Invalid documentation manifest at ${manifestPath}: "overlays" must be an array.`, ); } const overlays: DocumentationOverlay[] = parsed.overlays.map( (overlay, index) => { if ( typeof overlay !== "object" || overlay === null || !("file" in overlay) || (!("versionRange" in overlay) && !("sdkVersionRange" in overlay)) ) { throw new Error( `Invalid overlay entry at index ${index} in ${manifestPath}.`, ); } const { file, versionRange, sdkVersionRange } = overlay as { file: unknown; versionRange: unknown; sdkVersionRange: unknown; }; if (typeof file !== "string" || file.trim().length === 0) { throw new Error( `Invalid "file" for overlay index ${index} in ${manifestPath}.`, ); } const hasVersionRange = typeof versionRange === "string" && versionRange.trim().length > 0; const hasSdkVersionRange = typeof sdkVersionRange === "string" && sdkVersionRange.trim().length > 0; if ("versionRange" in overlay && !hasVersionRange) { throw new Error( `Invalid "versionRange" for overlay index ${index} in ${manifestPath}.`, ); } if ("sdkVersionRange" in overlay && !hasSdkVersionRange) { throw new Error( `Invalid "sdkVersionRange" for overlay index ${index} in ${manifestPath}.`, ); } if (!hasVersionRange && !hasSdkVersionRange) { throw new Error( `Overlay at index ${index} in ${manifestPath} must specify at least one of "versionRange" or "sdkVersionRange".`, ); } return { file, ...(hasVersionRange ? { versionRange: versionRange as string } : {}), ...(hasSdkVersionRange ? { sdkVersionRange: sdkVersionRange as string } : {}), }; }, ); const base = typeof parsed.base === "string" && parsed.base.trim().length > 0 ? parsed.base : undefined; return { base, overlays }; } function appendOverlay( baseDocumentation: string, overlayDocumentation: string, ): string { if (overlayDocumentation.trim().length === 0) { return baseDocumentation; } const normalizedBase = baseDocumentation.replace(/\s+$/, ""); const normalizedOverlay = overlayDocumentation.replace(/^\s+/, ""); return `${normalizedBase}\n\n${normalizedOverlay}`; } function trimNewlinePadding(content: string): string { return content.replace(/^\n+/, "").replace(/\n+$/, ""); } function parseOverlayOperations( overlayDocumentation: string, ): OverlayOperation[] { const lines = overlayDocumentation.split("\n"); const operations: OverlayOperation[] = []; const appendBuffer: string[] = []; const flushAppendBuffer = (): void => { const appendedContent = appendBuffer.join("\n"); appendBuffer.length = 0; if (appendedContent.trim().length === 0) { return; } operations.push({ type: "append", content: appendedContent, }); }; for (let index = 0; index < lines.length; index += 1) { const line = lines[index]; const replaceDirectiveMatch = line.match(/^#{1,6}\s+@replace:\s*(.+?)\s*$/); if (!replaceDirectiveMatch) { appendBuffer.push(line); continue; } flushAppendBuffer(); const sectionTitle = replaceDirectiveMatch[1]?.trim(); if (!sectionTitle) { throw new Error(`Invalid @replace directive: missing section title.`); } const replaceBodyLines: string[] = []; index += 1; while (index < lines.length) { const nextLine = lines[index]; if (/^#{1,6}\s+@replace:\s*(.+?)\s*$/.test(nextLine)) { index -= 1; break; } replaceBodyLines.push(nextLine); index += 1; } operations.push({ type: "replace", sectionTitle, content: trimNewlinePadding(replaceBodyLines.join("\n")), }); } flushAppendBuffer(); return operations; } function parseHeading(line: string): { level: number; title: string } | null { const headingMatch = line.match(/^(#{1,6})\s+(.*?)\s*$/); if (!headingMatch) { return null; } return { level: headingMatch[1].length, title: headingMatch[2].trim(), }; } function replaceMarkdownSection( documentation: string, sectionTitle: string, replacementContent: string, ): string { const lines = documentation.split("\n"); let startIndex = -1; let headingLevel = -1; for (let index = 0; index < lines.length; index += 1) { const heading = parseHeading(lines[index]); if (!heading) { continue; } if (heading.title === sectionTitle) { startIndex = index; headingLevel = heading.level; break; } } if (startIndex < 0) { throw new Error( `Overlay could not find section "${sectionTitle}" to replace.`, ); } let endIndex = lines.length; for (let index = startIndex + 1; index < lines.length; index += 1) { const heading = parseHeading(lines[index]); if (!heading) { continue; } if (heading.level <= headingLevel) { endIndex = index; break; } } const headingLine = lines[startIndex]; const replacementLines = [headingLine]; if (replacementContent.length > 0) { replacementLines.push(...replacementContent.split("\n")); } const nextLines = [ ...lines.slice(0, startIndex), ...replacementLines, ...lines.slice(endIndex), ]; return nextLines.join("\n"); } function applyOverlay( baseDocumentation: string, overlayDocumentation: string, ): string { const operations = parseOverlayOperations(overlayDocumentation); if (operations.length === 0) { return baseDocumentation; } let nextDocumentation = baseDocumentation; for (const operation of operations) { if (operation.type === "append") { nextDocumentation = appendOverlay(nextDocumentation, operation.content); continue; } nextDocumentation = replaceMarkdownSection( nextDocumentation, operation.sectionTitle, operation.content, ); } return nextDocumentation; } function splitComparators(versionRange: string): string[] { const pattern = /(>=|<=|>|<|=)?\s*(\d+\.\d+\.\d+)/g; const comparators: string[] = []; let match: RegExpExecArray | null; while ((match = pattern.exec(versionRange)) !== null) { comparators.push(`${match[1] ?? "="}${match[2]}`); } return comparators; } function parseSemVer(version: string): [number, number, number] { const match = version.match(/^(\d+)\.(\d+)\.(\d+)$/); if (!match) { throw new Error(`Invalid semver: "${version}"`); } return [ Number.parseInt(match[1], 10), Number.parseInt(match[2], 10), Number.parseInt(match[3], 10), ]; } function compareSemVer(left: string, right: string): number { const leftParts = parseSemVer(left); const rightParts = parseSemVer(right); for (let index = 0; index < leftParts.length; index += 1) { if (leftParts[index] > rightParts[index]) { return 1; } if (leftParts[index] < rightParts[index]) { return -1; } } return 0; } function satisfiesComparator(version: SemVer, comparator: string): boolean { const match = comparator.match(/^(>=|<=|>|<|=)?(.+)$/); if (!match) { throw new Error(`Invalid version comparator: "${comparator}"`); } const operator = match[1] ?? "="; const comparedVersion = match[2]?.trim(); if (!comparedVersion) { throw new Error(`Invalid version comparator: "${comparator}"`); } const comparison = compareSemVer(version, comparedVersion); switch (operator) { case ">": return comparison > 0; case ">=": return comparison >= 0; case "<": return comparison < 0; case "<=": return comparison <= 0; case "=": return comparison === 0; default: throw new Error(`Unsupported version operator: "${operator}"`); } } function versionMatchesRange(version: SemVer, versionRange: string): boolean { const comparators = splitComparators(versionRange); if (comparators.length === 0) { throw new Error(`Version range must not be empty`); } return comparators.every((comparator) => satisfiesComparator(version, comparator), ); } function ensureOverlayPathIsWithinPluginDir( pluginDirectory: string, overlayPath: string, ): void { if (!overlayPath.startsWith(`${pluginDirectory}/`)) { throw new Error( `Overlay path "${overlayPath}" must be inside plugin directory "${pluginDirectory}".`, ); } } function overlayMatchesVersions( overlay: DocumentationOverlay, pluginVersion: SemVer | undefined, sdkVersion: SemVer | undefined, ): boolean { if ( overlay.versionRange && (!pluginVersion || !versionMatchesRange(pluginVersion, overlay.versionRange)) ) { return false; } if ( overlay.sdkVersionRange && (!sdkVersion || !versionMatchesRange(sdkVersion, overlay.sdkVersionRange)) ) { return false; } return true; } export async function resolveIntegrationDocumentation( pluginId: string, options: ResolveIntegrationDocumentationOptions = {}, ): Promise { const { pluginVersion, sdkVersion, integrationsDirectory = INTEGRATIONS_DIR, } = options; const pluginDirectory = resolve( integrationsDirectory, getDocDirectoryForPlugin(pluginId), ); const manifestPath = resolve(pluginDirectory, "docs.manifest.json"); const manifest = await parseDocumentationManifest(manifestPath); const baseFileName = manifest?.base ?? "README.md"; const baseDocumentationPath = resolve(pluginDirectory, baseFileName); const baseDocumentation = await readFile(baseDocumentationPath, "utf8"); if (!manifest) { return baseDocumentation; } let resolvedDocumentation = baseDocumentation; for (const overlay of manifest.overlays) { if (!overlayMatchesVersions(overlay, pluginVersion, sdkVersion)) { continue; } const overlayPath = resolve(pluginDirectory, overlay.file); ensureOverlayPathIsWithinPluginDir(pluginDirectory, overlayPath); const overlayDocumentation = await readFile(overlayPath, "utf8"); resolvedDocumentation = applyOverlay( resolvedDocumentation, overlayDocumentation, ); } return resolvedDocumentation; }