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
12 changes: 6 additions & 6 deletions packages/kbn-docs-utils/src/README.md
Original file line number Diff line number Diff line change
@@ -1,25 +1,25 @@
# Autogenerated API documentation.
# Autogenerated API documentation

[RFC](https://github.com/elastic/kibana/blob/main/legacy_rfcs/text/0014_api_documentation.md).
[RFC](https://github.com/elastic/kibana/blob/main/legacy_rfcs/text/0014_api_documentation.md)

This package builds and validates API documentation for Kibana plugins and packages. Use `node scripts/build_api_docs` to emit docs to `api_docs/`, or `node scripts/check_package_docs` to validate JSDoc without writing files.

## CLI commands.
## CLI commands

### Build API docs (`node scripts/build_api_docs`).
### Build API docs (`node scripts/build_api_docs`)
- Generates docs into `api_docs/` using [`src/build_api_docs_cli.ts`](./build_api_docs_cli.ts).
- `--plugin <id>` limits to a single plugin or package; `--package` is an alias.
- `--references` collects references for API items.
- `--stats <any|comments|exports>` is deprecated and routes validation to `check_package_docs` without writing docs.

### Check package docs (`node scripts/check_package_docs`).
### Check package docs (`node scripts/check_package_docs`)
- Runs validation only (no docs written) via [`src/check_package_docs_cli.ts`](./check_package_docs_cli.ts); output folder is `api_docs_check/`.
- `--plugin <id>` and `--package <id>` filter targets; omit to check all plugins.
- `--check <any|comments|exports|all>` selects checks; defaults to `all` (equivalent to `any`, `comments`, and `exports`).
- Multiple `--check` flags combine checks.
- Exits with a non-zero code if any selected checks fail.

## Validation rules.
## Validation rules
- `any` check: fails when API declarations use `any` (`TypeKind.AnyKind`).
- `comments` check: fails when descriptions are missing for API items.
- `exports` check: fails when public API items are missing from plugin exports discovered during analysis.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -229,26 +229,34 @@ describe('Parameter extraction', () => {
const hiProp = paramObj!.children!.find((c) => c.label === 'hi');
expect(hiProp).toBeDefined();
expect(hiProp!.type).toBe(TypeKind.StringKind);
expect(hiProp!.description?.[0]).toContain('Greeting');

// Second parameter: { fn1, fn2 }: { fn1: Function, fn2: Function }
const paramFn = def.children!.find((c) => c.label === '{ fn1, fn2 }');
// Second parameter: fns: { fn1: Function, fn2: Function }
const paramFn = def.children!.find((c) => c.label === 'fns');
expect(paramFn).toBeDefined();
expect(paramFn!.children).toBeDefined();
expect(paramFn!.children!.length).toBe(2);

const fn1 = paramFn!.children!.find((c) => c.label === 'fn1');
expect(fn1).toBeDefined();
expect(fn1!.type).toBe(TypeKind.FunctionKind);
const fn1Desc = fn1?.description?.[0] ?? '';
expect(fn1Desc).toContain('first function');

const fn2 = paramFn!.children!.find((c) => c.label === 'fn2');
expect(fn2).toBeDefined();
expect(fn2!.type).toBe(TypeKind.FunctionKind);
const fn2Desc = fn2?.description?.[0] ?? '';
expect(fn2Desc).toContain('second function');

// Third parameter: { str }: { str: string }
const paramStr = def.children!.find((c) => c.label === '{ str }');
// Third parameter: strObj: { str: string }
const paramStr = def.children!.find((c) => c.label === 'strObj');
expect(paramStr).toBeDefined();
expect(paramStr!.children).toBeDefined();
expect(paramStr!.children!.length).toBe(1);

const strProp = paramStr!.children!.find((c) => c.label === 'str');
expect(strProp?.description?.[0]).toContain('string property');
});

it('extracts nested destructured parameters', () => {
Expand All @@ -262,8 +270,8 @@ describe('Parameter extraction', () => {
captureReferences: false,
});

// Check nested structure: { fn1, fn2 }.fn1.foo.param
const paramFn = def.children!.find((c) => c.label === '{ fn1, fn2 }');
// Check nested structure: fns.fn1.foo.param
const paramFn = def.children!.find((c) => c.label === 'fns');
expect(paramFn).toBeDefined();

const fn1 = paramFn!.children!.find((c) => c.label === 'fn1');
Expand Down Expand Up @@ -425,19 +433,12 @@ describe('Parameter extraction', () => {
// First parameter has @param obj comment
const paramObj = def.children!.find((c) => c.label === 'obj');
expect(paramObj).toBeDefined();
// Current behavior: parent parameter comments are NOT extracted for TypeLiteral parameters
// This is a known limitation - when a parameter has a TypeLiteral type (destructured params),
// buildApiDeclaration is called directly without extracting the JSDoc comment for the parameter name.
// This will be fixed in Phase 4.1
expect(paramObj!.description).toBeDefined();
// Currently, the description is empty for destructured parameters
// After Phase 4.1, this should contain the @param obj comment
expect(paramObj!.description!.length).toBe(0);
expect(paramObj!.description!.length).toBeGreaterThan(0);
expect(paramObj!.description![0]).toContain('crazy parameter');
});

it('does not extract property-level JSDoc comments (current limitation)', () => {
// This test documents current behavior: property-level @param tags like @param obj.hi
// are not currently extracted. This will be fixed in Phase 4.1.
it('extracts property-level JSDoc comments for destructured parameters', () => {
const node = nodes.find((n) => getNodeName(n) === 'crazyFunction');
expect(node).toBeDefined();
const def = buildApiDeclarationTopNode(node!, {
Expand All @@ -453,11 +454,9 @@ describe('Parameter extraction', () => {

const hiProp = paramObj!.children!.find((c) => c.label === 'hi');
expect(hiProp).toBeDefined();
// Current behavior: property-level comments are not extracted
// Even if @param obj.hi existed, it wouldn't be found
// This is a known limitation that will be addressed in Phase 4.1
expect(hiProp!.description).toBeDefined();
expect(hiProp!.description!.length).toBe(0);
expect(hiProp!.description!.length).toBeGreaterThan(0);
expect(hiProp!.description![0]).toContain('Greeting');
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,44 +10,206 @@
import type { ParameterDeclaration, JSDoc } from 'ts-morph';
import { SyntaxKind } from 'ts-morph';
import { extractImportReferences } from './extract_import_refs';
import type { ApiDeclaration } from '../types';
import type { ApiDeclaration, TextWithLinks } from '../types';
import { buildApiDeclaration } from './build_api_declaration';
import { getJSDocParamComment } from './js_doc_utils';
import { buildBasicApiDeclaration } from './build_basic_api_declaration';
import type { BuildApiDecOpts } from './types';
import { buildApiId, getOptsForChild } from './utils';

/**
* A helper function to capture function parameters, whether it comes from an arrow function, a regular function or
* a function type.
* Cache for pre-parsed JSDoc parameter comments, keyed by normalized parameter name.
*/
export function buildApiDecsForParameters(
params: ParameterDeclaration[],
type ParamCommentCache = Map<string, TextWithLinks>;

/**
* Removes braces and all whitespace in a name for cache key normalization.
*/
const normalizeForCache = (name: string): string => name.replace(/[{}\s]/g, '');

/**
* Removes braces and normalizes whitespace in a name.
*/
const cleanName = (name: string): string => name.replace(/[{}]/g, '').replace(/\s+/g, ' ').trim();

/**
* Removes braces and all whitespace in a name for tight matching.
*/
const normalizeTight = (name: string): string =>
name.replace(/[{}]/g, '').replace(/\s+/g, '').trim();

/**
* Pre-parses all `@param` entries from JSDoc into a cache for efficient lookups.
* This avoids re-parsing raw JSDoc text for each parameter in deep object structures.
*/
const buildParamCommentCache = (jsDocs: JSDoc[] | undefined): ParamCommentCache => {
const cache: ParamCommentCache = new Map();

if (!jsDocs) {
return cache;
}

for (const jsDoc of jsDocs) {
const text = jsDoc.getText();
const lines = text.split(/\r?\n/);

for (const line of lines) {
const trimmed = line.replace(/^\s*\*\s?/, '');
if (!trimmed.includes('@param')) {
continue;
}

const body = trimmed.trim().replace(/^@param\s+/, '');
const parts = body.split(/\s+/);
if (parts.length === 0) {
continue;
}

// Skip type annotation if present (e.g., "{string} paramName").
let nameIndex = 0;
if (parts[0].startsWith('{')) {
while (nameIndex < parts.length && !parts[nameIndex].endsWith('}')) {
nameIndex += 1;
}
nameIndex += 1;
}

const nameToken = parts[nameIndex];
if (!nameToken) {
continue;
}

const commentText = parts
.slice(nameIndex + 1)
.join(' ')
.trim();
if (commentText) {
const normalizedKey = normalizeForCache(nameToken);
cache.set(normalizedKey, [commentText]);
}
}
}

return cache;
};

/**
* Generates candidate path strings for matching JSDoc parameter comments.
* JSDoc can reference parameters in various formats, so we generate multiple variations.
*/
const generatePathCandidates = (path: string[]): string[] => {
if (path.length === 0) {
return [];
}

const candidates = new Set<string>();

// Original path with dots
candidates.add(path.join('.'));

// Path with cleaned first element (normalized whitespace)
const cleanedFirst = cleanName(path[0]);
candidates.add([cleanedFirst, ...path.slice(1)].join('.'));

// Path with all elements normalized tightly (no whitespace)
const tightPath = path.map(normalizeTight).join('.');
candidates.add(tightPath);

// For single-element paths, also include the raw name
if (path.length === 1) {
candidates.add(path[0]);
}

return Array.from(candidates).filter(Boolean);
};

/**
* Looks up a parameter comment from the cache using candidate path strings.
*/
const lookupParamComment = (
cache: ParamCommentCache,
path: string[]
): TextWithLinks | undefined => {
const candidates = generatePathCandidates(path);
for (const candidate of candidates) {
const normalizedKey = normalizeForCache(candidate);
const comment = cache.get(normalizedKey);
if (comment) {
return comment;
}
}
return undefined;
};

/**
* Applies JSDoc parameter comments to an API declaration and its children recursively.
* Uses a pre-built cache to avoid re-parsing JSDoc text for each node.
*/
const applyParamComments = (
apiDec: ApiDeclaration,
cache: ParamCommentCache,
path: string[]
): void => {
if (cache.size === 0 || path.length === 0) {
return;
}

const comment = lookupParamComment(cache, path);

if (comment && comment.length > 0) {
apiDec.description = comment;
}

// Recursively apply comments to children.
if (apiDec.children) {
apiDec.children.forEach((child) => {
applyParamComments(child, cache, [...path, child.label]);
});
}
};

/**
* Builds an API declaration for a single parameter.
*/
const buildParameterDeclaration = (
param: ParameterDeclaration,
index: number,
parentOpts: BuildApiDecOpts,
jsDocs?: JSDoc[]
): ApiDeclaration[] {
return params.reduce((acc, param, index) => {
const id = buildApiId(`$${index + 1}`, parentOpts.id);
const opts = {
...getOptsForChild(param, parentOpts),
id,
};

opts.log.debug(`Getting parameter doc def for ${opts.name} of kind ${param.getKindName()}`);
// Literal types are non primitives that aren't references to other types. We add them as a more
// defined node, with children.
// If we don't want the docs to be too deeply nested we could avoid this special handling.
if (param.getTypeNode() && param.getTypeNode()!.getKind() === SyntaxKind.TypeLiteral) {
acc.push(buildApiDeclaration(param.getTypeNode()!, opts));
} else {
const apiDec = buildBasicApiDeclaration(param, opts);
acc.push({
...apiDec,
cache: ParamCommentCache
): ApiDeclaration => {
const id = buildApiId(`$${index + 1}`, parentOpts.id);
const opts = {
...getOptsForChild(param, parentOpts),
id,
};

opts.log.debug(`Getting parameter doc def for ${opts.name} of kind ${param.getKindName()}`);

const typeNode = param.getTypeNode();
const isTypeLiteral = typeNode?.getKind() === SyntaxKind.TypeLiteral;

// Type literals are inline object types that should be expanded with children.
// Other types are handled as basic declarations with signatures.
const apiDec = isTypeLiteral
? buildApiDeclaration(typeNode!, opts)
: {
...buildBasicApiDeclaration(param, opts),
isRequired: param.getType().isNullable() === false,
signature: extractImportReferences(param.getType().getText(), opts.plugins, opts.log),
description: jsDocs ? getJSDocParamComment(jsDocs, opts.name) : [],
});
}
return acc;
}, [] as ApiDeclaration[]);
}
};

applyParamComments(apiDec, cache, [opts.name]);
return apiDec;
};

/**
* Builds API declarations for function parameters, whether from arrow functions,
* regular functions, or function types.
*/
export const buildApiDecsForParameters = (
params: ParameterDeclaration[],
parentOpts: BuildApiDecOpts,
jsDocs?: JSDoc[]
): ApiDeclaration[] => {
const cache = buildParamCommentCache(jsDocs);
return params.map((param, index) => buildParameterDeclaration(param, index, parentOpts, cache));
};
Loading
Loading