fbredius/storybook

View on GitHub
addons/docs/src/lib/jsdocParser.ts

Summary

Maintainability
C
1 day
Test Coverage
import doctrine, { Annotation } from 'doctrine';

export interface ExtractedJsDocParam {
  name: string;
  type?: any;
  description?: string;
  getPrettyName: () => string;
  getTypeName: () => string;
}

export interface ExtractedJsDocReturns {
  type?: any;
  description?: string;
  getTypeName: () => string;
}

export interface ExtractedJsDoc {
  params?: ExtractedJsDocParam[];
  returns?: ExtractedJsDocReturns;
  ignore: boolean;
}

export interface JsDocParsingOptions {
  tags?: string[];
}

export interface JsDocParsingResult {
  includesJsDoc: boolean;
  ignore: boolean;
  description?: string;
  extractedTags?: ExtractedJsDoc;
}

export type ParseJsDoc = (value?: string, options?: JsDocParsingOptions) => JsDocParsingResult;

function containsJsDoc(value?: string): boolean {
  return value != null && value.includes('@');
}

function parse(content: string, tags: string[]): Annotation {
  let ast;

  try {
    ast = doctrine.parse(content, {
      tags,
      sloppy: true,
    });
  } catch (e) {
    // eslint-disable-next-line no-console
    console.error(e);

    throw new Error('Cannot parse JSDoc tags.');
  }

  return ast;
}

const DEFAULT_OPTIONS = {
  tags: ['param', 'arg', 'argument', 'returns', 'ignore'],
};

export const parseJsDoc: ParseJsDoc = (
  value?: string,
  options: JsDocParsingOptions = DEFAULT_OPTIONS
) => {
  if (!containsJsDoc(value)) {
    return {
      includesJsDoc: false,
      ignore: false,
    };
  }

  const jsDocAst = parse(value, options.tags);
  const extractedTags = extractJsDocTags(jsDocAst);

  if (extractedTags.ignore) {
    // There is no point in doing other stuff since this prop will not be rendered.
    return {
      includesJsDoc: true,
      ignore: true,
    };
  }

  return {
    includesJsDoc: true,
    ignore: false,
    // Always use the parsed description to ensure JSDoc is removed from the description.
    description: jsDocAst.description,
    extractedTags,
  };
};

function extractJsDocTags(ast: doctrine.Annotation): ExtractedJsDoc {
  const extractedTags: ExtractedJsDoc = {
    params: null,
    returns: null,
    ignore: false,
  };

  for (let i = 0; i < ast.tags.length; i += 1) {
    const tag = ast.tags[i];

    if (tag.title === 'ignore') {
      extractedTags.ignore = true;
      // Once we reach an @ignore tag, there is no point in parsing the other tags since we will not render the prop.
      break;
    } else {
      switch (tag.title) {
        // arg & argument are aliases for param.
        case 'param':
        case 'arg':
        case 'argument': {
          const paramTag = extractParam(tag);
          if (paramTag != null) {
            if (extractedTags.params == null) {
              extractedTags.params = [];
            }
            extractedTags.params.push(paramTag);
          }
          break;
        }
        case 'returns': {
          const returnsTag = extractReturns(tag);
          if (returnsTag != null) {
            extractedTags.returns = returnsTag;
          }
          break;
        }
        default:
          break;
      }
    }
  }

  return extractedTags;
}

function extractParam(tag: doctrine.Tag): ExtractedJsDocParam {
  const paramName = tag.name;

  // When the @param doesn't have a name but have a type and a description, "null-null" is returned.
  if (paramName != null && paramName !== 'null-null') {
    return {
      name: tag.name,
      type: tag.type,
      description: tag.description,
      getPrettyName: () => {
        if (paramName.includes('null')) {
          // There is a few cases in which the returned param name contains "null".
          // - @param {SyntheticEvent} event- Original SyntheticEvent
          // - @param {SyntheticEvent} event.\n@returns {string}
          return paramName.replace('-null', '').replace('.null', '');
        }

        return tag.name;
      },
      getTypeName: () => {
        return tag.type != null ? extractTypeName(tag.type) : null;
      },
    };
  }

  return null;
}

function extractReturns(tag: doctrine.Tag): ExtractedJsDocReturns {
  if (tag.type != null) {
    return {
      type: tag.type,
      description: tag.description,
      getTypeName: () => {
        return extractTypeName(tag.type);
      },
    };
  }

  return null;
}

function extractTypeName(type: doctrine.Type): string {
  if (type.type === 'NameExpression') {
    return type.name;
  }

  if (type.type === 'RecordType') {
    const recordFields = type.fields.map((field: doctrine.type.FieldType) => {
      if (field.value != null) {
        const valueTypeName = extractTypeName(field.value);

        return `${field.key}: ${valueTypeName}`;
      }

      return field.key;
    });

    return `({${recordFields.join(', ')}})`;
  }

  if (type.type === 'UnionType') {
    const unionElements = type.elements.map(extractTypeName);

    return `(${unionElements.join('|')})`;
  }

  // Only support untyped array: []. Might add more support later if required.
  if (type.type === 'ArrayType') {
    return '[]';
  }

  if (type.type === 'TypeApplication') {
    if (type.expression != null) {
      if ((type.expression as doctrine.type.NameExpression).name === 'Array') {
        const arrayType = extractTypeName(type.applications[0]);

        return `${arrayType}[]`;
      }
    }
  }

  if (
    type.type === 'NullableType' ||
    type.type === 'NonNullableType' ||
    type.type === 'OptionalType'
  ) {
    return extractTypeName(type.expression);
  }

  if (type.type === 'AllLiteral') {
    return 'any';
  }

  return null;
}