diff --git a/.gitignore b/.gitignore index 1c6f4ac..794bc4b 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ yarn-error.log* # Build output dist/ build/ +cache/ *.tsbuildinfo # Environment variables diff --git a/package.json b/package.json index ee09c10..a9ba51a 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "type": "module", "imports": { "~docsCatalog": "./src/docs.json", + "#buildDocs": "./dist/patternFly.buildDocs.js", "#toolsHost": "./dist/server.toolsHost.js" }, "exports": { @@ -27,6 +28,7 @@ "scripts": { "build": "npm run build:clean; npm run test:types; pkgroll", "build:clean": "rm -rf dist", + "build:api": "node dist/cli.js --mode docs --log-stderr", "build:watch": "npm run build -- --watch", "release": "changelog --non-cc --link-url https://github.com/patternfly/patternfly-mcp.git", "start": "node dist/cli.js --log-stderr", diff --git a/src/__tests__/__snapshots__/options.defaults.test.ts.snap b/src/__tests__/__snapshots__/options.defaults.test.ts.snap index 606d4cf..17de1dc 100644 --- a/src/__tests__/__snapshots__/options.defaults.test.ts.snap +++ b/src/__tests__/__snapshots__/options.defaults.test.ts.snap @@ -41,12 +41,19 @@ exports[`options defaults should return specific properties: defaults 1`] = ` "mode": "programmatic", "modeOptions": { "cli": {}, + "docs": {}, "programmatic": {}, "test": {}, }, "name": "@patternfly/patternfly-mcp", "nodeVersion": 22, "patternflyOptions": { + "api": { + "endpoints": { + "v6": "https://patternfly-doc-core.pages.dev/api/v6", + }, + "expireDays": 14, + }, "availableResourceVersions": [ "6.0.0", ], @@ -70,6 +77,7 @@ exports[`options defaults should return specific properties: defaults 1`] = ` }, "urlWhitelist": [ "https://patternfly.org", + "https://patternfly-doc-core.pages.dev", "https://github.com/patternfly", "https://raw.githubusercontent.com/patternfly", ], diff --git a/src/__tests__/__snapshots__/options.test.ts.snap b/src/__tests__/__snapshots__/options.test.ts.snap index 03c577f..7311c45 100644 --- a/src/__tests__/__snapshots__/options.test.ts.snap +++ b/src/__tests__/__snapshots__/options.test.ts.snap @@ -18,6 +18,7 @@ exports[`parseCliOptions should attempt to parse args with --allowed-hosts 1`] = }, "modeOptions": { "cli": {}, + "docs": {}, "programmatic": {}, "test": {}, }, @@ -44,6 +45,7 @@ exports[`parseCliOptions should attempt to parse args with --allowed-origins 1`] }, "modeOptions": { "cli": {}, + "docs": {}, "programmatic": {}, "test": {}, }, @@ -67,6 +69,7 @@ exports[`parseCliOptions should attempt to parse args with --http and --host 1`] }, "modeOptions": { "cli": {}, + "docs": {}, "programmatic": {}, "test": {}, }, @@ -90,6 +93,7 @@ exports[`parseCliOptions should attempt to parse args with --http and --port 1`] }, "modeOptions": { "cli": {}, + "docs": {}, "programmatic": {}, "test": {}, }, @@ -113,6 +117,7 @@ exports[`parseCliOptions should attempt to parse args with --http and invalid -- }, "modeOptions": { "cli": {}, + "docs": {}, "programmatic": {}, "test": {}, }, @@ -134,6 +139,7 @@ exports[`parseCliOptions should attempt to parse args with --http flag 1`] = ` }, "modeOptions": { "cli": {}, + "docs": {}, "programmatic": {}, "test": {}, }, @@ -155,6 +161,7 @@ exports[`parseCliOptions should attempt to parse args with --log-level flag 1`] }, "modeOptions": { "cli": {}, + "docs": {}, "programmatic": {}, "test": {}, }, @@ -176,6 +183,7 @@ exports[`parseCliOptions should attempt to parse args with --log-stderr flag and }, "modeOptions": { "cli": {}, + "docs": {}, "programmatic": {}, "test": {}, }, @@ -197,6 +205,7 @@ exports[`parseCliOptions should attempt to parse args with --tool 1`] = ` }, "modeOptions": { "cli": {}, + "docs": {}, "programmatic": {}, "test": {}, }, @@ -221,6 +230,7 @@ exports[`parseCliOptions should attempt to parse args with --verbose flag 1`] = }, "modeOptions": { "cli": {}, + "docs": {}, "programmatic": {}, "test": {}, }, @@ -242,6 +252,7 @@ exports[`parseCliOptions should attempt to parse args with --verbose flag and -- }, "modeOptions": { "cli": {}, + "docs": {}, "programmatic": {}, "test": {}, }, @@ -263,6 +274,7 @@ exports[`parseCliOptions should attempt to parse args with other arguments 1`] = }, "modeOptions": { "cli": {}, + "docs": {}, "programmatic": {}, "test": {}, }, diff --git a/src/__tests__/api.json.test.ts b/src/__tests__/api.json.test.ts new file mode 100644 index 0000000..feffbe6 --- /dev/null +++ b/src/__tests__/api.json.test.ts @@ -0,0 +1,30 @@ +import apiJson from '../api.json'; + +describe('api.json', () => { + it('should have a valid top-level generated timestamp (ISO date string)', () => { + expect(apiJson.generated).toBeDefined(); + expect(typeof apiJson.generated).toBe('string'); + expect(apiJson.generated.length).toBeGreaterThan(0); + + const rawDate = apiJson.generated; + const parsedDate = Date.parse(rawDate); + + expect(Number.isNaN(parsedDate)).toBe(false); + + // Canonical ISO 8601 UTC form from Date.prototype.toISOString() + expect(new Date(parsedDate).toISOString()).toBe(rawDate); + }); + + it('should have a valid meta structure', () => { + expect(apiJson.meta).toBeDefined(); + expect(apiJson.meta.totalEntries).toBeDefined(); + expect(apiJson.meta.totalDocs).toBeDefined(); + expect(apiJson.meta.source).toBe('patternfly-mcp-api'); + expect(apiJson.meta.lastBuildRun).toBeDefined(); + }); + + it('should have a docs object', () => { + expect(apiJson.docs).toBeDefined(); + expect(typeof apiJson.docs).toBe('object'); + }); +}); diff --git a/src/api.json b/src/api.json new file mode 100644 index 0000000..4c7571c --- /dev/null +++ b/src/api.json @@ -0,0 +1,11 @@ +{ + "version": "1", + "generated": "2026-03-23T20:00:00.000Z", + "meta": { + "totalEntries": 0, + "totalDocs": 0, + "source": "patternfly-mcp-api", + "lastBuildRun": "2026-03-23T20:00:00.000Z" + }, + "docs": {} +} diff --git a/src/docs.embedded.ts b/src/docs.embedded.ts index 6c09d30..40dcdde 100644 --- a/src/docs.embedded.ts +++ b/src/docs.embedded.ts @@ -35,6 +35,7 @@ interface PatternFlyMcpDocsCatalog { totalEntries: number; totalDocs: number; source: string; + [key: string]: unknown; }; docs: PatternFlyMcpDocsCatalogEntry } diff --git a/src/index.ts b/src/index.ts index f063977..c332ea5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -176,6 +176,22 @@ const main = async ( // Finalize exit policy after merging options updatedAllowProcessExit = allowProcessExit ?? mergedOptions.mode !== 'test'; + + // Handle documentation building mode + if (mergedOptions.mode === 'docs') { + const { createLogger } = await import('./logger'); + const { buildPatternFlyDocs } = await import('#buildDocs' as string); + + createLogger(); + + const instance = await buildPatternFlyDocs(mergedOptions); + + if (updatedAllowProcessExit) { + process.exit(0); + } + + return instance as any; + } } catch (error) { processExit('Set options error, failed to start server:', error); } diff --git a/src/options.defaults.ts b/src/options.defaults.ts index 66ac08f..b3492be 100644 --- a/src/options.defaults.ts +++ b/src/options.defaults.ts @@ -21,6 +21,7 @@ import { type ToolModule } from './server.toolsUser'; * - `cli`: Command-line interface mode. * - `programmatic`: Programmatic interaction mode where the application is used as a library or API. * - `test`: Testing or debugging mode. + * - `docs`: Documentation mode for building PatternFly documentation. * @property {ModeOptions} modeOptions - Mode-specific options. * @property name - Name of the package. * @property nodeVersion - Node.js major version. @@ -51,7 +52,7 @@ interface DefaultOptions { isHttp: boolean; logging: TLogOptions; minMax: MinMax; - mode: 'cli' | 'programmatic' | 'test'; + mode: 'cli' | 'programmatic' | 'test' | 'docs'; modeOptions: ModeOptions; name: string; nodeVersion: number; @@ -170,6 +171,7 @@ interface ModeOptions { test?: { baseUrl?: string | undefined; } | undefined; + docs?: object | undefined; } /** @@ -205,6 +207,13 @@ interface PatternFlyOptions { versionWhitelist: string[]; versionStrategy: 'highest' | 'lowest'; }, + api: { + expireDays: number; + endpoints: { + v6: WhitelistUrl; + v5?: WhitelistUrl; + } + }, urlWhitelist: WhitelistUrl[]; urlWhitelistProtocols: string[]; } @@ -348,7 +357,8 @@ const MIN_MAX: MinMax = { const MODE_OPTIONS: ModeOptions = { cli: {}, programmatic: {}, - test: {} + test: {}, + docs: {} }; /** @@ -455,8 +465,15 @@ const PATTERNFLY_OPTIONS: PatternFlyOptions = { ], versionStrategy: 'highest' }, + api: { + expireDays: 14, + endpoints: { + v6: 'https://patternfly-doc-core.pages.dev/api/v6' + } + }, urlWhitelist: [ 'https://patternfly.org', + 'https://patternfly-doc-core.pages.dev', 'https://github.com/patternfly', 'https://raw.githubusercontent.com/patternfly' ], @@ -471,7 +488,7 @@ const URL_REGEX = /^(https?:)\/\//i; /** * Available operational modes for the MCP server. */ -const MODE_LEVELS: DefaultOptions['mode'][] = ['cli', 'programmatic', 'test']; +const MODE_LEVELS: DefaultOptions['mode'][] = ['cli', 'programmatic', 'test', 'docs']; /** * Get the current Node.js major version. diff --git a/src/patternFly.buildDocs.ts b/src/patternFly.buildDocs.ts new file mode 100644 index 0000000..a07cb6c --- /dev/null +++ b/src/patternFly.buildDocs.ts @@ -0,0 +1,190 @@ +import { writeFileSync, mkdirSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { log, formatUnknownError, subscribeToChannel, type LogEvent } from './logger'; +import { getOptions, getSessionOptions, runWithSession } from './options.context'; +import { type GlobalOptions } from './options'; +import { type PatternFlyMcpDocsCatalog, type PatternFlyMcpDocsCatalogDoc } from './docs.embedded'; +import { toCamelCase, toDisplayName, joinUrl } from './server.helpers'; +import { loadFileFetch } from './server.getResources'; + +/** + * Statistics for the documentation build. + */ +interface DocsStats { + generated: string; + totalEntries: number; + lastBuildRun: number; +} + +/** + * Interface for a documentation build instance. + */ +interface DocsInstance { + stop(): Promise; + isRunning(): boolean; + getStats(): Promise; + onLog(handler: (entry: LogEvent) => void): () => void; +} + +/** + * Recursively spider through documentation API segments. + * + * @param baseUrl - The current URL to spider + * @param parts - Accumulated path parts (e.g., [version, section, page]) + * @param context - Shared context for the spider run + * @param context.version + * @param catalog - The catalog to populate + * @returns A promise that resolves when the current segment and its children are processed + */ +const spiderSegments = async ( + baseUrl: string, + parts: string[], + context: { version: string }, + catalog: PatternFlyMcpDocsCatalog +): Promise => { + try { + const { content, resolvedPath } = await loadFileFetch(baseUrl); + let segments: unknown; + + try { + segments = JSON.parse(content); + } catch { + // If not JSON, it's terminal content + segments = null; + } + + // Terminal Detection: Non-array or explicitly requested terminal path + const isTerminal = !Array.isArray(segments) || resolvedPath.endsWith('/text'); + + if (isTerminal) { + const page = parts[parts.length - 2] || 'unknown'; + const section = parts[1] || 'other'; + const category = parts[parts.length - 1] || 'general'; + + const unifiedName = toCamelCase(page); + const entry: PatternFlyMcpDocsCatalogDoc = { + displayName: `${toDisplayName(page)} (${toDisplayName(category)})`, + description: `PatternFly ${toDisplayName(section)} documentation for ${page} (${category}).`, + pathSlug: page.replace(/_/g, '-'), + section, + category, + source: 'api', + version: context.version, + path: resolvedPath + }; + + /* eslint-disable no-param-reassign */ + catalog.docs[unifiedName] ??= []; + catalog.docs[unifiedName].push(entry); + catalog.meta.totalEntries += 1; + catalog.meta.totalDocs += 1; + + log.info('Build docs', ` [${catalog.meta.totalDocs}] Added entry for ${page} (${category})`); + + return; + } + + // If array, it's a directory: recurse + const childSegments = segments as string[]; + + for (const segment of childSegments) { + await spiderSegments(joinUrl(baseUrl, segment), [...parts, segment], context, catalog); + } + } catch (error) { + log.error(`Build docs`, `API spider failed for ${baseUrl}: ${formatUnknownError(error)}`); + } +}; + +/** + * Documentation builder for PatternFly MCP. + * Consumes the PatternFly Astro API to generate a dynamic api.json catalog. + * + * @param options - Global options for the build + * @returns A promise that resolves to a DocsInstance + */ +const buildPatternFlyDocs = async (options: GlobalOptions = getOptions()): Promise => { + const session = getSessionOptions(); + let running = true; + const startTime = Date.now(); + + const stats: DocsStats = { + generated: new Date().toISOString(), + totalEntries: 0, + lastBuildRun: 0 + }; + + const catalog: PatternFlyMcpDocsCatalog = { + version: '1', + generated: stats.generated, + meta: { + totalEntries: 0, + totalDocs: 0, + source: 'api', + lastBuildRun: startTime + }, + docs: {} + }; + + const buildPromise = runWithSession(session, async () => { + log.info('Build docs', 'Starting PatternFly documentation build...'); + + const { patternflyOptions, contextPath } = options; + const { endpoints } = patternflyOptions.api; + + for (const [version, apiBase] of Object.entries(endpoints)) { + if (!apiBase) { + continue; + } + + log.info('Build docs', `Processing version ${version} from ${apiBase}`); + + const context = { + version + }; + + // Ensure we don't double up on the version if it's already in the apiBase + const rootUrl = apiBase.includes(version) + ? apiBase + : joinUrl(apiBase, version); + + // Recursively spider from the version root + await spiderSegments(rootUrl, [version], context, catalog); + } + + stats.totalEntries = catalog.meta.totalDocs; + stats.lastBuildRun = Date.now() - startTime; + catalog.meta.lastBuildRun = stats.lastBuildRun; + + const cacheDir = join(contextPath, 'cache'); + + if (!existsSync(cacheDir)) { + mkdirSync(cacheDir, { recursive: true }); + } + + const cachePath = join(cacheDir, 'api.dynamic.json'); + + writeFileSync(cachePath, JSON.stringify(catalog, null, 2)); + + log.info('Build docs', `Build complete. Generated ${catalog.meta.totalDocs} entries in ${stats.lastBuildRun}ms.`); + log.info('Build docs', `Cache written to ${cachePath}`); + + running = false; + }); + + // Revisit this, blocking the output is counter to what we want. Firs round, for this mode, we wait for completion to satisfy the CLI + await buildPromise; + + return { + stop: async () => { running = false; }, + isRunning: () => running, + getStats: async () => stats, + onLog: handler => subscribeToChannel(handler) + }; +}; + +export { + spiderSegments, + buildPatternFlyDocs, + type DocsStats, + type DocsInstance +}; diff --git a/src/patternFly.getResources.ts b/src/patternFly.getResources.ts index 761df14..c415880 100644 --- a/src/patternFly.getResources.ts +++ b/src/patternFly.getResources.ts @@ -1,3 +1,5 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; import { componentNames as pfComponentNames, getComponentSchema @@ -168,27 +170,144 @@ interface PatternFlyMcpAvailableResources extends PatternFlyVersionContext { byVersionComponentNames: PatternFlyMcpComponentNames['byVersion']; } +/** + * Merges two API catalogs. Dynamic cache takes precedence over baseline. + * + * @param baseline - Baseline API catalog + * @param dynamic - Dynamic API catalog + * @returns Merged API catalog + */ +const mergeApiData = (baseline: PatternFlyMcpDocsCatalog, dynamic: PatternFlyMcpDocsCatalog): PatternFlyMcpDocsCatalog => { + const mergedDocs = { ...baseline.docs }; + + Object.entries(dynamic.docs || {}).forEach(([name, entries]) => { + if (mergedDocs[name]) { + mergedDocs[name] = [...mergedDocs[name], ...(entries as PatternFlyMcpDocsCatalogDoc[])]; + } else { + mergedDocs[name] = entries as PatternFlyMcpDocsCatalogDoc[]; + } + }); + + return { + ...baseline, + ...dynamic, + meta: { + ...baseline.meta, + ...(dynamic.meta || {}) + }, + docs: mergedDocs + }; +}; + +/** + * Merges API data and embedded docs, deduplicating based on resource signatures. + * + * @param apiData - Merged API catalog + * @param embeddedDocs - Embedded documentation catalog + * @returns Merged and deduplicated catalog + */ +const mergeAndDeduplicate = (apiData: PatternFlyMcpDocsCatalog, embeddedDocs: PatternFlyMcpDocsCatalog): PatternFlyMcpDocsCatalog => { + const apiDocs = apiData.docs || {}; + const embeddedDocsMap = embeddedDocs.docs || {}; + + // 1. Create a Set of signatures already covered by the API + const apiSignatures = new Set(); + + Object.entries(apiDocs).forEach(([name, entries]) => { + (entries as PatternFlyMcpDocsCatalogDoc[]).forEach(entry => { + const signature = `${name.toLowerCase()}|${(entry.version || '').toLowerCase()}|${(entry.section || '').toLowerCase()}|${(entry.category || '').toLowerCase()}`; + + apiSignatures.add(signature); + }); + }); + + // 2. Filter embedded docs: Only keep what isn't in the API + const filteredEmbeddedDocs: Record = {}; + const duplicatesFound: string[] = []; + + Object.entries(embeddedDocsMap).forEach(([name, entries]) => { + const remainingEntries = (entries as PatternFlyMcpDocsCatalogDoc[]).filter(entry => { + const signature = `${name.toLowerCase()}|${(entry.version || '').toLowerCase()}|${(entry.section || '').toLowerCase()}|${(entry.category || '').toLowerCase()}`; + + if (apiSignatures.has(signature)) { + duplicatesFound.push(signature); + + return false; // Skip this embedded entry + } + + return true; + }); + + if (remainingEntries.length > 0) { + filteredEmbeddedDocs[name] = remainingEntries; + } + }); + + // 3. Combine: API data is the primary, filtered embedded is the fallback + const mergedDocs = { ...filteredEmbeddedDocs }; + + Object.entries(apiDocs).forEach(([name, entries]) => { + if (mergedDocs[name]) { + // Correctly merge the arrays to preserve both embedded (non-duplicate) and API entries + mergedDocs[name] = [...mergedDocs[name], ...(entries as PatternFlyMcpDocsCatalogDoc[])]; + } else { + mergedDocs[name] = entries as PatternFlyMcpDocsCatalogDoc[]; + } + }); + + return { + ...apiData, + docs: mergedDocs, + meta: { + ...apiData.meta, + duplicatesOffset: duplicatesFound.length, + offsetLog: duplicatesFound + } + }; +}; + /** * Lazy load the PatternFly documentation catalog. * * @returns PatternFly documentation catalog JSON, or fallback catalog if import fails. */ const getPatternFlyDocsCatalog = async (): Promise => { - let docsCatalog = EMBEDDED_DOCS; - let isFallback = false; + let embeddedDocs = EMBEDDED_DOCS; + let apiBaseline = { docs: {}, meta: {} } as PatternFlyMcpDocsCatalog; try { if (process.env.NODE_ENV === 'local') { - docsCatalog = (await import('./docs.json', { with: { type: 'json' } })).default; + embeddedDocs = (await import('./docs.json', { with: { type: 'json' } })).default; + apiBaseline = (await import('./api.json', { with: { type: 'json' } })).default; } else { - docsCatalog = (await import('#docsCatalog', { with: { type: 'json' } })).default; + embeddedDocs = (await import('#docsCatalog' as string, { with: { type: 'json' } })).default; + apiBaseline = (await import('#apiCatalog' as string, { with: { type: 'json' } })).default; + } + + // Attempt to load dynamic cache from local storage + let dynamicCache = { docs: {} } as PatternFlyMcpDocsCatalog; + + try { + // Path will eventually be configurable via options.defaults + const cachePath = join(process.cwd(), 'cache', 'api.dynamic.json'); + + if (existsSync(cachePath)) { + dynamicCache = JSON.parse(readFileSync(cachePath, 'utf-8')); + } + } catch (error) { + log.debug(`No dynamic API cache found, proceeding with baseline: ${formatUnknownError(error)}`); } + + // Merge API Baseline + Dynamic Cache + const apiCatalog = mergeApiData(apiBaseline, dynamicCache); + + // Deduplicate: Prefer API data and "Offset" embedded docs + return { ...mergeAndDeduplicate(apiCatalog, embeddedDocs), isFallback: false }; } catch (error) { - isFallback = true; - log.debug(`Failed to import docs catalog '#docsCatalog': ${formatUnknownError(error)}`, 'Using fallback docs catalog.'); - } + log.debug(`Failed to import docs catalog: ${formatUnknownError(error)}`, 'Using fallback docs catalog.'); - return { ...docsCatalog, isFallback }; + return { ...EMBEDDED_DOCS, isFallback: true }; + } }; /** diff --git a/src/server.helpers.ts b/src/server.helpers.ts index 110b722..8367245 100644 --- a/src/server.helpers.ts +++ b/src/server.helpers.ts @@ -535,6 +535,46 @@ stringJoin.filtered = (...args: unknown[]) => stringJoin(args, { filterFalsyValu */ stringJoin.newlineFiltered = (...args: unknown[]) => stringJoin(args, { sep: '\n', filterFalsyValues: true }); +/** + * Joins multiple URL segments into a single URL string, ensuring no double slashes. + * If `base` is not a valid URL, it's returned as-is. + * + * @param base - The base URL string + * @param parts - Additional path segments to join + * @returns The joined URL string + */ +const joinUrl = (base: string, ...parts: string[]): string => { + if (!isUrl(base)) { + return base; + } + + const url = new URL(base); + + parts.join('/').split('/').filter(Boolean).forEach(part => { + const updatedPathname = url.pathname.endsWith('/') ? url.pathname : `${url.pathname}/`; + + url.pathname = `${updatedPathname}${part}`; + }); + + return url.toString(); +}; + +/** + * Normalizes a string to a CamelCase name. + * + * @param str - string to normalize (e.g., "about_modal", "alert") + * @returns CamelCase name (e.g., "AboutModal", "Alert") + */ +const toCamelCase = (str: string) => str.split(/[_-]/).map(part => part.charAt(0).toUpperCase() + part.slice(1)).join(''); + +/** + * Normalizes a string to a space-separated display name. + * + * @param str - string to normalize (e.g., "about_modal", "alert") + * @returns Display name (e.g., "About Modal", "Alert") + */ +const toDisplayName = (str: string) => str.split(/[_-]/).map(part => part.charAt(0).toUpperCase() + part.slice(1)).join(' '); + /** * Construct a search/query string from an object of key-value pairs, optionally filtering out * specific values and adding a `?` prefix. @@ -614,11 +654,14 @@ export { isReferenceLike, isUrl, isWhitelistedUrl, + joinUrl, listAllCombinations, listIncrementalCombinations, mergeObjects, portValid, splitUri, stringJoin, + toCamelCase, + toDisplayName, timeoutFunction }; diff --git a/tsconfig.json b/tsconfig.json index c874d62..706f26d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -23,6 +23,8 @@ "stripInternal": true, "rootDirs": ["./src", "./tests/e2e", "./tests/audit"], "paths": { + "#apiCatalog": ["./src/api.json"], + "#buildDocs": ["./src/patternFly.buildDocs.ts"], "#docsCatalog": ["./src/docs.json"], "#toolsHost": ["./src/server.toolsHost.ts"] }