diff --git a/.changeset/stupid-shrimps-promise.md b/.changeset/stupid-shrimps-promise.md new file mode 100644 index 0000000000..c33958c9ea --- /dev/null +++ b/.changeset/stupid-shrimps-promise.md @@ -0,0 +1,16 @@ +--- +'@lit-labs/analyzer': minor +'@lit-labs/cli': minor +'@lit-labs/gen-manifest': minor +--- + +Added CLI improvements: + +- Add support for --exclude options (important for excluding test files from e.g. manifest or wrapper generation) + +Added more analysis support and manifest emit: + +- TS enum type variables +- description, summary, and deprecated for all models +- module-level description & summary +- ClassField and ClassMethod diff --git a/packages/labs/analyzer/.vscode/launch.json b/packages/labs/analyzer/.vscode/launch.json index f5e40122fb..7a6d4967e3 100644 --- a/packages/labs/analyzer/.vscode/launch.json +++ b/packages/labs/analyzer/.vscode/launch.json @@ -11,6 +11,7 @@ "skipFiles": ["/**"], "program": "${workspaceFolder}/../../../node_modules/uvu/bin.js", "args": ["test", "\\_test\\.js$"], + "env": {"NODE_OPTIONS": "--enable-source-maps"}, "outFiles": ["${workspaceFolder}/**/*.js"], "console": "integratedTerminal" } diff --git a/packages/labs/analyzer/src/index.ts b/packages/labs/analyzer/src/index.ts index 3aabd2b437..ed488abebd 100644 --- a/packages/labs/analyzer/src/index.ts +++ b/packages/labs/analyzer/src/index.ts @@ -16,10 +16,15 @@ export type { Declaration, VariableDeclaration, ClassDeclaration, + ClassField, + ClassMethod, + Parameter, + Return, LitElementDeclaration, LitElementExport, PackageJson, ModuleWithLitElementDeclarations, + DeprecatableDescribed, } from './lib/model.js'; export type {AbsolutePath, PackagePath} from './lib/paths.js'; diff --git a/packages/labs/analyzer/src/lib/analyze-package.ts b/packages/labs/analyzer/src/lib/analyze-package.ts index 959b3a5c86..1a7d36a39f 100644 --- a/packages/labs/analyzer/src/lib/analyze-package.ts +++ b/packages/labs/analyzer/src/lib/analyze-package.ts @@ -1,9 +1,25 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + import ts from 'typescript'; import {AbsolutePath} from './paths.js'; import * as path from 'path'; import {DiagnosticsError} from './errors.js'; import {Analyzer} from './analyzer.js'; +export interface AnalyzerOptions { + /** + * Glob of source files to exclude from project during analysis. + * + * Useful for excluding things source like test folders that might otherwise + * be included in a project's tsconfig. + */ + exclude?: string[]; +} + /** * Returns an analyzer for a Lit npm package based on a filesystem path. * @@ -11,7 +27,10 @@ import {Analyzer} from './analyzer.js'; * specifying a folder, if no tsconfig.json file is found directly in the root * folder, the project will be analyzed as JavaScript. */ -export const createPackageAnalyzer = (packagePath: AbsolutePath) => { +export const createPackageAnalyzer = ( + packagePath: AbsolutePath, + options: AnalyzerOptions = {} +) => { // This logic accepts either a path to folder containing a tsconfig.json // directly inside it or a path to a specific tsconfig file. If no tsconfig // file is found, we fallback to creating a Javascript program. @@ -22,6 +41,9 @@ export const createPackageAnalyzer = (packagePath: AbsolutePath) => { let commandLine: ts.ParsedCommandLine; if (ts.sys.fileExists(configFileName)) { const configFile = ts.readConfigFile(configFileName, ts.sys.readFile); + if (options.exclude !== undefined) { + (configFile.config.exclude ??= []).push(...options.exclude); + } commandLine = ts.parseJsonConfigFileContent( configFile.config /* json */, ts.sys /* host */, @@ -55,6 +77,7 @@ export const createPackageAnalyzer = (packagePath: AbsolutePath) => { moduleResolution: 'node', }, include: ['**/*.js'], + exclude: options.exclude ?? [], }, ts.sys /* host */, packagePath /* basePath */ diff --git a/packages/labs/analyzer/src/lib/javascript/classes.ts b/packages/labs/analyzer/src/lib/javascript/classes.ts index 50aabacfcc..7d8b2b5200 100644 --- a/packages/labs/analyzer/src/lib/javascript/classes.ts +++ b/packages/labs/analyzer/src/lib/javascript/classes.ts @@ -18,12 +18,23 @@ import { DeclarationInfo, ClassHeritage, Reference, + ClassField, + ClassMethod, } from '../model.js'; import { isLitElementSubclass, getLitElementDeclaration, } from '../lit-element/lit-element.js'; -import {hasExportKeyword, getReferenceForIdentifier} from '../references.js'; +import {getReferenceForIdentifier} from '../references.js'; +import {parseNodeJSDocInfo} from './jsdoc.js'; +import { + hasDefaultModifier, + hasStaticModifier, + hasExportModifier, + getPrivacy, +} from '../utils.js'; +import {getFunctionLikeInfo} from './functions.js'; +import {getTypeForNode} from '../types.js'; /** * Returns an analyzer `ClassDeclaration` model for the given @@ -41,7 +52,54 @@ const getClassDeclaration = ( name: declaration.name?.text ?? '', node: declaration, getHeritage: () => getHeritage(declaration, analyzer), + ...parseNodeJSDocInfo(declaration), + ...getClassMembers(declaration, analyzer), + }); +}; + +/** + * Returns the `fields` and `methods` of a class. + */ +export const getClassMembers = ( + declaration: ts.ClassDeclaration, + analyzer: AnalyzerInterface +) => { + const fieldMap = new Map(); + const methodMap = new Map(); + declaration.members.forEach((node) => { + if (ts.isMethodDeclaration(node)) { + methodMap.set( + node.name.getText(), + new ClassMethod({ + ...getMemberInfo(node), + ...getFunctionLikeInfo(node, analyzer), + ...parseNodeJSDocInfo(node), + }) + ); + } else if (ts.isPropertyDeclaration(node)) { + fieldMap.set( + node.name.getText(), + new ClassField({ + ...getMemberInfo(node), + default: node.initializer?.getText(), + type: getTypeForNode(node, analyzer), + ...parseNodeJSDocInfo(node), + }) + ); + } }); + return { + fieldMap, + methodMap, + }; +}; + +const getMemberInfo = (node: ts.MethodDeclaration | ts.PropertyDeclaration) => { + return { + name: node.name.getText(), + static: hasStaticModifier(node), + privacy: getPrivacy(node), + }; }; /** @@ -52,9 +110,7 @@ const getClassDeclarationName = (declaration: ts.ClassDeclaration) => { declaration.name?.text ?? // The only time a class declaration will not have a name is when it is // a default export, aka `export default class { }` - (declaration.modifiers?.some((s) => s.kind === ts.SyntaxKind.DefaultKeyword) - ? 'default' - : undefined); + (hasDefaultModifier(declaration) ? 'default' : undefined); if (name === undefined) { throw new DiagnosticsError( declaration, @@ -74,7 +130,7 @@ export const getClassDeclarationInfo = ( return { name: getClassDeclarationName(declaration), factory: () => getClassDeclaration(declaration, analyzer), - isExport: hasExportKeyword(declaration), + isExport: hasExportModifier(declaration), }; }; diff --git a/packages/labs/analyzer/src/lib/javascript/functions.ts b/packages/labs/analyzer/src/lib/javascript/functions.ts new file mode 100644 index 0000000000..d4dfdba756 --- /dev/null +++ b/packages/labs/analyzer/src/lib/javascript/functions.ts @@ -0,0 +1,81 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +/** + * @fileoverview + * + * Utilities for analyzing function declarations + */ + +import ts from 'typescript'; +import {DiagnosticsError} from '../errors.js'; +import {AnalyzerInterface, Parameter, Return} from '../model.js'; +import {getTypeForNode, getTypeForType} from '../types.js'; +import {parseJSDocDescription} from './jsdoc.js'; + +/** + * Returns information on FunctionLike nodes + */ +export const getFunctionLikeInfo = ( + node: ts.FunctionLikeDeclaration, + analyzer: AnalyzerInterface +) => { + return { + parameters: node.parameters.map((p) => getParameter(p, analyzer)), + return: getReturn(node, analyzer), + }; +}; + +const getParameter = ( + param: ts.ParameterDeclaration, + analyzer: AnalyzerInterface +): Parameter => { + const paramTag = ts.getAllJSDocTagsOfKind( + param, + ts.SyntaxKind.JSDocParameterTag + )[0]; + const p: Parameter = { + name: param.name.getText(), + type: getTypeForNode(param, analyzer), + ...(paramTag ? parseJSDocDescription(paramTag) : {}), + optional: false, + rest: false, + }; + if (param.initializer !== undefined) { + p.optional = true; + p.default = param.initializer.getText(); + } + if (param.questionToken !== undefined) { + p.optional = true; + } + if (param.dotDotDotToken !== undefined) { + p.rest = true; + } + return p; +}; + +const getReturn = ( + node: ts.FunctionLikeDeclaration, + analyzer: AnalyzerInterface +): Return => { + const returnTag = ts.getAllJSDocTagsOfKind( + node, + ts.SyntaxKind.JSDocReturnTag + )[0]; + const signature = analyzer.program + .getTypeChecker() + .getSignatureFromDeclaration(node); + if (signature === undefined) { + throw new DiagnosticsError( + node, + `Could not get signature to determine return type` + ); + } + return { + type: getTypeForType(signature.getReturnType(), node, analyzer), + ...(returnTag ? parseJSDocDescription(returnTag) : {}), + }; +}; diff --git a/packages/labs/analyzer/src/lib/javascript/jsdoc.ts b/packages/labs/analyzer/src/lib/javascript/jsdoc.ts index 5b448618cc..01730dfdf9 100644 --- a/packages/labs/analyzer/src/lib/javascript/jsdoc.ts +++ b/packages/labs/analyzer/src/lib/javascript/jsdoc.ts @@ -4,9 +4,20 @@ * SPDX-License-Identifier: BSD-3-Clause */ +/** + * @fileoverview + * + * Utilities for analyzing JSDoc comments + */ + import ts from 'typescript'; import {DiagnosticsError} from '../errors.js'; -import {AnalyzerInterface, NamedJSDocInfo, NodeJSDocInfo} from '../model.js'; +import { + Described, + NamedDescribed, + TypedNamedDescribed, + DeprecatableDescribed, +} from '../model.js'; /** * @fileoverview @@ -15,120 +26,264 @@ import {AnalyzerInterface, NamedJSDocInfo, NodeJSDocInfo} from '../model.js'; */ /** - * Remove line feeds from JSDoc summaries, so they are normalized to + * Returns true if given node has a JSDoc tag + */ +export const hasJSDocTag = (node: ts.Node, tag: string) => { + return ts.getJSDocTags(node).some((t) => t.tagName.text === tag); +}; + +/** + * Remove line feeds from JSDoc comments, so they are normalized to * unix `\n` line endings. */ -const normalizeLineEndings = (s: string) => s.replace(/\r/g, ''); +const normalizeLineEndings = (s: string) => s.replace(/\r/g, '').trim(); -// Regex for parsing name, summary, and descriptions from JSDoc comments -const parseNameDescSummaryRE = - /^\s*(?[^\s:]+)([\s-:]+)?(?[^\n\r]+)?([\n\r]+(?[\s\S]*))?$/m; +// Regex for parsing name, type, and description from JSDoc comments +const parseNameTypeDescRE = + /^(?\S+)(?:\s+{(?.*)})?(?:\s+-\s+)?(?[\s\S]*)$/m; -// Regex for parsing summary and description from JSDoc comments -const parseDescSummaryRE = - /^\s*(?[^\n\r]+)\r?\n\r?\n(?[\s\S]*)$/m; +// Regex for parsing name and description from JSDoc comments +const parseNameDescRE = /^(?^\S+)(?:\s?-\s+)?(?[\s\S]*)$/m; + +// Regex for parsing optional name and description from JSDoc comments, where +// the dash is required before the description (syntax for `@slot` tag, whose +// default slot has no name) +const parseNameDashDescRE = + /^(?^\S*)?(?:\s+-\s+(?[\s\S]*))?$/m; + +const getJSDocTagComment = (tag: ts.JSDocTag) => { + let {comment} = tag; + if (comment === undefined) { + return undefined; + } + if (Array.isArray(comment)) { + comment = comment.map((c) => c.text).join(''); + } + if (typeof comment !== 'string') { + throw new DiagnosticsError(tag, `Internal error: unsupported node type`); + } + return normalizeLineEndings(comment).trim(); +}; + +const isModuleJSDocTag = (tag: ts.JSDocTag) => + tag.tagName.text === 'module' || + tag.tagName.text === 'fileoverview' || + tag.tagName.text === 'packageDocumentation'; + +/** + * Parses name, type, and description from JSDoc tag for things like `@fires`. + * + * Supports the following patterns following the tag (TS parses the tag for us): + * * @fires event-name + * * @fires event-name description + * * @fires event-name - description + * * @fires event-name: description + * * @fires event-name {Type} + * * @fires event-name {Type} description + * * @fires event-name {Type} - description + * * @fires event-name {Type}: description + */ +export const parseNamedTypedJSDocInfo = (tag: ts.JSDocTag) => { + const comment = getJSDocTagComment(tag); + if (comment == undefined) { + return undefined; + } + const nameTypeDesc = comment.match(parseNameTypeDescRE); + if (nameTypeDesc === null) { + throw new DiagnosticsError(tag, 'Unexpected JSDoc format'); + } + const {name, type, description} = nameTypeDesc.groups!; + const info: TypedNamedDescribed = {name, type}; + if (description.length > 0) { + info.description = normalizeLineEndings(description); + } + return info; +}; /** - * Parses name, summary, and description from JSDoc tag for things like @slot, - * @cssPart, and @cssProp. + * Parses name and description from JSDoc tag for things like `@slot`, + * `@cssPart`, and `@cssProp`. * * Supports the following patterns following the tag (TS parses the tag for us): * * @slot name - * * @slot name summary - * * @slot name: summary - * * @slot name - summary - * * @slot name - summary... - * * - * * description (multiline) + * * @slot name description + * * @slot name - description + * * @slot name: description */ -export const parseNameDescSummary = ( - tag: ts.JSDocTag -): NamedJSDocInfo | undefined => { - const {comment} = tag; +export const parseNamedJSDocInfo = ( + tag: ts.JSDocTag, + requireDash = false +): NamedDescribed | undefined => { + const comment = getJSDocTagComment(tag); if (comment == undefined) { return undefined; } - if (typeof comment !== 'string') { - throw new DiagnosticsError(tag, `Internal error: unsupported node type`); + const nameDesc = comment.match( + requireDash ? parseNameDashDescRE : parseNameDescRE + ); + if (nameDesc === null) { + throw new DiagnosticsError( + tag, + `Unexpected JSDoc format.${ + parseNameDashDescRE + ? ` Tag must contain a whitespace-separated dash between the name and description, i.e. '@slot header - This is the description'` + : '' + }` + ); } - const nameDescSummary = comment.match(parseNameDescSummaryRE); - if (nameDescSummary === null) { - throw new DiagnosticsError(tag, 'Unexpected JSDoc format'); + const {name, description} = nameDesc.groups!; + const info: NamedDescribed = {name}; + if (description.length > 0) { + info.description = normalizeLineEndings(description); + } + return info; +}; + +/** + * Parses the description from JSDoc tag for things like `@return`. + */ +export const parseJSDocDescription = ( + tag: ts.JSDocTag +): Described | undefined => { + const description = getJSDocTagComment(tag); + if (description == undefined || description.length === 0) { + return {}; + } + return {description}; +}; + +/** + * Add `@description`, `@summary`, and `@deprecated` JSDoc tag info to the + * given info object. + */ +const addJSDocTagInfo = ( + info: DeprecatableDescribed, + jsDocTags: readonly ts.JSDocTag[] +) => { + for (const tag of jsDocTags) { + const comment = getJSDocTagComment(tag); + switch (tag.tagName.text.toLowerCase()) { + case 'description': + case 'fileoverview': + case 'packagedocumentation': + if (comment !== undefined) { + info.description = comment; + } + break; + case 'summary': + if (comment !== undefined) { + info.summary = comment; + } + break; + case 'deprecated': + info.deprecated = comment !== undefined ? comment : true; + break; + } } - const {name, description, summary} = nameDescSummary.groups!; - const v: NamedJSDocInfo = {name}; - if (summary !== undefined) { - v.summary = summary; +}; + +const moduleJSDocsMap = new WeakMap(); + +/** + * Returns the module-level JSDoc comment blocks for a given source file. + * + * Note that TS does not have a concept of module-level JSDoc; if it + * exists, it will always be attached to the first statement in the module. + * + * Thus, we parse module-level documentation using the following heuristic: + * - If the first statement only has one JSDoc block, it is only treated as + * module documentation if it contains a `@module`, `@fileoverview`, or + * `@packageDocumentation` tag. This is required to disambiguate a module + * description (with an undocumented first statement) from documentation + * for the first statement. + * - If the first statement has more than one JSDoc block, we collect all + * but the last and use those, regardless of whether they contain one + * of the above module-designating tags (the last one is assumed to belong + * to the first statement). + * + * This function caches its result against the given sourceFile, since it is + * needed both to find the module comment and to filter out module comments + * node comments. + */ +const getModuleJSDocs = (sourceFile: ts.SourceFile) => { + let moduleJSDocs = moduleJSDocsMap.get(sourceFile); + if (moduleJSDocs !== undefined) { + return moduleJSDocs; } - if (description !== undefined) { - v.description = normalizeLineEndings(description); + // Get the first child in the sourceFile; note that returning the first child + // from `ts.forEachChild` is more robust than `sourceFile.getChildAt(0)`, + // since `forEachChild` flattens embedded arrays that the child APIs would + // otherwise return. + const firstChild = ts.forEachChild(sourceFile, (n) => n); + if (firstChild === undefined) { + moduleJSDocs = []; + } else { + // Get the JSDoc blocks attached to the first child (they oddly show up + // in the node's children) + const jsDocs = firstChild.getChildren().filter(ts.isJSDoc); + // If there is more than one leading JSDoc block, grab all but the last, + // otherwise grab the one (see heuristic above) + moduleJSDocs = jsDocs.slice(0, jsDocs.length > 1 ? -1 : 1); + // If there is only one leading JSDoc block, it must have a module tag + if (jsDocs.length === 1 && !jsDocs[0].tags?.some(isModuleJSDocTag)) { + moduleJSDocs = []; + } } - return v; + moduleJSDocsMap.set(sourceFile, moduleJSDocs!); + return moduleJSDocs; }; /** * Parse summary, description, and deprecated information from JSDoc comments on * a given node. */ -export const parseNodeJSDocInfo = ( - node: ts.Node, - analyzer: AnalyzerInterface -): NodeJSDocInfo => { - const v: NodeJSDocInfo = {}; +export const parseNodeJSDocInfo = (node: ts.Node): DeprecatableDescribed => { + const info: DeprecatableDescribed = {}; const jsDocTags = ts.getJSDocTags(node); if (jsDocTags !== undefined) { - for (const tag of jsDocTags) { - const {comment} = tag; - if (comment !== undefined && typeof comment !== 'string') { - throw new DiagnosticsError( - tag, - `Internal error: unsupported node type` - ); - } - switch (tag.tagName.text) { - case 'description': - if (comment !== undefined) { - v.description = normalizeLineEndings(comment); - } - break; - case 'summary': - if (comment !== undefined) { - v.summary = comment; - } - break; - case 'deprecated': - v.deprecated = comment !== undefined ? comment : true; - break; - } + addJSDocTagInfo(info, jsDocTags); + } + if (info.description === undefined) { + const moduleJSDocs = getModuleJSDocs(node.getSourceFile()); + const comment = normalizeLineEndings( + node + .getChildren() + .filter(ts.isJSDoc) + // Module-level docs (that are explicitly tagged as such) may be + // attached to the first declaration if the declaration is undocumented, + // so we filter those out since they shouldn't apply to a + // declaration node + .filter((c) => !moduleJSDocs.includes(c)) + .map((n) => n.comment) + .filter((c) => c !== undefined) + .join('\n') + ); + if (comment.length > 0) { + info.description = comment; } } - // If we didn't have a tagged @description, we'll use any untagged text as - // the description. If we also didn't have a @summary and the untagged text - // has a line break, we'll use the first chunk as the summary, and the - // remainder as a description. - if (v.description === undefined) { - // Strangely, it only seems possible to get the untagged jsdoc comments - // via the typechecker/symbol API - const checker = analyzer.program.getTypeChecker(); - const symbol = - checker.getSymbolAtLocation(node) ?? - checker.getTypeAtLocation(node).getSymbol(); - const comments = symbol?.getDocumentationComment(checker); - if (comments !== undefined) { - const comment = comments.map((c) => c.text).join('\n'); - if (v.summary !== undefined) { - v.description = normalizeLineEndings(comment); - } else { - const info = comment.match(parseDescSummaryRE); - if (info === null) { - v.description = normalizeLineEndings(comment); - } else { - const {summary, description} = info.groups!; - v.summary = summary; - v.description = normalizeLineEndings(description); - } - } + return info; +}; + +/** + * Parse summary, description, and deprecated information from JSDoc comments on + * a given source file. + */ +export const parseModuleJSDocInfo = (sourceFile: ts.SourceFile) => { + const moduleJSDocs = getModuleJSDocs(sourceFile); + const info: DeprecatableDescribed = {}; + addJSDocTagInfo( + info, + moduleJSDocs.flatMap((m) => m.tags ?? []) + ); + if (info.description === undefined) { + const comment = moduleJSDocs + .map((d) => d.comment) + .filter((c) => c !== undefined) + .join('\n'); + if (comment.length > 0) { + info.description = comment; } } - return v; + return info; }; diff --git a/packages/labs/analyzer/src/lib/javascript/modules.ts b/packages/labs/analyzer/src/lib/javascript/modules.ts index c3a207ce03..b42b70882f 100644 --- a/packages/labs/analyzer/src/lib/javascript/modules.ts +++ b/packages/labs/analyzer/src/lib/javascript/modules.ts @@ -26,6 +26,7 @@ import {getClassDeclarationInfo} from './classes.js'; import { getExportAssignmentVariableDeclarationInfo, getVariableDeclarationInfo, + getEnumDeclarationInfo, } from './variables.js'; import {AbsolutePath, PackagePath, absoluteToPackage} from '../paths.js'; import {getPackageInfo} from './packages.js'; @@ -35,6 +36,7 @@ import { getImportReferenceForSpecifierExpression, getSpecifierString, } from '../references.js'; +import {parseModuleJSDocInfo} from './jsdoc.js'; /** * Returns the sourcePath, jsPath, and package.json contents of the containing @@ -98,6 +100,11 @@ export const getModule = ( const reexports: ts.Expression[] = []; const addDeclaration = (info: DeclarationInfo) => { const {name, factory, isExport} = info; + if (declarationMap.has(name)) { + throw new Error( + `Internal error: duplicate declaration '${name}' in ${sourceFile.fileName}` + ); + } declarationMap.set(name, factory); if (isExport) { exportMap.set(name, name); @@ -111,6 +118,8 @@ export const getModule = ( addDeclaration(getClassDeclarationInfo(statement, analyzer)); } else if (ts.isVariableStatement(statement)) { getVariableDeclarationInfo(statement, analyzer).forEach(addDeclaration); + } else if (ts.isEnumDeclaration(statement)) { + addDeclaration(getEnumDeclarationInfo(statement, analyzer)); } else if (ts.isExportDeclaration(statement) && !statement.isTypeOnly) { const {exportClause, moduleSpecifier} = statement; if (exportClause === undefined) { @@ -151,6 +160,7 @@ export const getModule = ( dependencies, exportMap, finalizeExports: () => finalizeExports(reexports, exportMap, analyzer), + ...parseModuleJSDocInfo(sourceFile), }); analyzer.moduleCache.set( analyzer.path.normalize(sourceFile.fileName) as AbsolutePath, diff --git a/packages/labs/analyzer/src/lib/javascript/variables.ts b/packages/labs/analyzer/src/lib/javascript/variables.ts index 31458ec513..e43b0d903e 100644 --- a/packages/labs/analyzer/src/lib/javascript/variables.ts +++ b/packages/labs/analyzer/src/lib/javascript/variables.ts @@ -15,10 +15,12 @@ import { VariableDeclaration, AnalyzerInterface, DeclarationInfo, + DeprecatableDescribed, } from '../model.js'; -import {hasExportKeyword} from '../references.js'; +import {hasExportModifier} from '../utils.js'; import {DiagnosticsError} from '../errors.js'; import {getTypeForNode} from '../types.js'; +import {parseNodeJSDocInfo} from './jsdoc.js'; type VariableName = | ts.Identifier @@ -30,14 +32,16 @@ type VariableName = * ts.Identifier within a potentially nested ts.VariableDeclaration. */ const getVariableDeclaration = ( - dec: ts.VariableDeclaration, + dec: ts.VariableDeclaration | ts.EnumDeclaration, name: ts.Identifier, + jsDocInfo: DeprecatableDescribed, analyzer: AnalyzerInterface ): VariableDeclaration => { return new VariableDeclaration({ name: name.text, node: dec, type: getTypeForNode(name, analyzer), + ...jsDocInfo, }); }; @@ -49,9 +53,12 @@ export const getVariableDeclarationInfo = ( statement: ts.VariableStatement, analyzer: AnalyzerInterface ): DeclarationInfo[] => { - const isExport = hasExportKeyword(statement); + const isExport = hasExportModifier(statement); + const jsDocInfo = parseNodeJSDocInfo(statement); return statement.declarationList.declarations - .map((d) => getVariableDeclarationInfoList(d, d.name, isExport, analyzer)) + .map((d) => + getVariableDeclarationInfoList(d, d.name, isExport, jsDocInfo, analyzer) + ) .flat(); }; @@ -64,13 +71,14 @@ const getVariableDeclarationInfoList = ( dec: ts.VariableDeclaration, name: VariableName, isExport: boolean, + jsDocInfo: DeprecatableDescribed, analyzer: AnalyzerInterface ): DeclarationInfo[] => { if (ts.isIdentifier(name)) { return [ { name: name.text, - factory: () => getVariableDeclaration(dec, name, analyzer), + factory: () => getVariableDeclaration(dec, name, jsDocInfo, analyzer), isExport, }, ]; @@ -85,7 +93,13 @@ const getVariableDeclarationInfoList = ( ) as ts.BindingElement[]; return els .map((el) => - getVariableDeclarationInfoList(dec, el.name, isExport, analyzer) + getVariableDeclarationInfoList( + dec, + el.name, + isExport, + jsDocInfo, + analyzer + ) ) .flat(); } else { @@ -127,5 +141,19 @@ const getExportAssignmentVariableDeclaration = ( name: 'default', node: exportAssignment, type: getTypeForNode(exportAssignment.expression, analyzer), + ...parseNodeJSDocInfo(exportAssignment), }); }; + +export const getEnumDeclarationInfo = ( + statement: ts.EnumDeclaration, + analyzer: AnalyzerInterface +) => { + const jsDocInfo = parseNodeJSDocInfo(statement); + return { + name: statement.name.text, + factory: () => + getVariableDeclaration(statement, statement.name, jsDocInfo, analyzer), + isExport: hasExportModifier(statement), + }; +}; diff --git a/packages/labs/analyzer/src/lib/lit-element/events.ts b/packages/labs/analyzer/src/lib/lit-element/events.ts index 37cc2794d7..9e7139145c 100644 --- a/packages/labs/analyzer/src/lib/lit-element/events.ts +++ b/packages/labs/analyzer/src/lib/lit-element/events.ts @@ -11,10 +11,10 @@ */ import ts from 'typescript'; -import {DiagnosticsError} from '../errors.js'; +import {parseNamedTypedJSDocInfo} from '../javascript/jsdoc.js'; import {Event} from '../model.js'; import {AnalyzerInterface} from '../model.js'; -import {getTypeForJSDocTag} from '../types.js'; +import {getTypeForTypeString} from '../types.js'; /** * Returns an array of analyzer `Event` models for the given @@ -25,48 +25,15 @@ export const addEventsToMap = ( events: Map, analyzer: AnalyzerInterface ) => { - const {comment} = tag; - if (comment === undefined) { + const info = parseNamedTypedJSDocInfo(tag); + if (info === undefined) { return; - } else if (typeof comment === 'string') { - const result = parseFiresTagComment(comment); - if (result === undefined) { - throw new DiagnosticsError( - tag, - 'The @fires annotation was not in a recognized form. ' + - 'Use `@fires event-name {Type} - Description`.' - ); - } - const {name, type, description} = result; - events.set(name, { - name, - type: type ? getTypeForJSDocTag(tag, analyzer) : undefined, - description, - }); - } else { - // TODO: when do we get a ts.NodeArray? - throw new DiagnosticsError(tag, `Internal error: unsupported node type`); } -}; - -const parseFiresTagComment = (comment: string) => { - // Valid variants: - // @fires event-name - // @fires event-name The event description - // @fires event-name - The event description - // @fires event-name {EventType} - // @fires event-name {EventType} The event description - // @fires event-name {EventType} - The event description - const eventCommentRegex = - /^(?\S+)(?:\s+{(?.*)})?(?:\s+(?:-\s+)?(?.+))?$/; - const match = comment.match(eventCommentRegex); - if (match === null) { - return undefined; - } - const {name, type, description} = match.groups!; - return { + const {name, type, description, summary} = info; + events.set(name, { name, - type, + type: type ? getTypeForTypeString(type, tag, analyzer) : undefined, description, - }; + summary, + }); }; diff --git a/packages/labs/analyzer/src/lib/lit-element/lit-element.ts b/packages/labs/analyzer/src/lib/lit-element/lit-element.ts index 7bb26fc219..3d92891a3c 100644 --- a/packages/labs/analyzer/src/lib/lit-element/lit-element.ts +++ b/packages/labs/analyzer/src/lib/lit-element/lit-element.ts @@ -11,13 +11,13 @@ */ import ts from 'typescript'; -import {getHeritage} from '../javascript/classes.js'; -import {parseNodeJSDocInfo, parseNameDescSummary} from '../javascript/jsdoc.js'; +import {getClassMembers, getHeritage} from '../javascript/classes.js'; +import {parseNodeJSDocInfo, parseNamedJSDocInfo} from '../javascript/jsdoc.js'; import { LitElementDeclaration, AnalyzerInterface, Event, - NamedJSDocInfo, + NamedDescribed, } from '../model.js'; import {isCustomElementDecorator} from './decorators.js'; import {addEventsToMap} from './events.js'; @@ -28,17 +28,18 @@ import {getProperties} from './properties.js'; * (branded as LitClassDeclaration). */ export const getLitElementDeclaration = ( - node: LitClassDeclaration, + declaration: LitClassDeclaration, analyzer: AnalyzerInterface ): LitElementDeclaration => { return new LitElementDeclaration({ - tagname: getTagName(node), + tagname: getTagName(declaration), // TODO(kschaaf): support anonymous class expressions when assigned to a const - name: node.name?.text ?? '', - node, - reactiveProperties: getProperties(node, analyzer), - ...getJSDocData(node, analyzer), - getHeritage: () => getHeritage(node, analyzer), + name: declaration.name?.text ?? '', + node: declaration, + reactiveProperties: getProperties(declaration, analyzer), + ...getJSDocData(declaration, analyzer), + getHeritage: () => getHeritage(declaration, analyzer), + ...getClassMembers(declaration, analyzer), }); }; @@ -51,9 +52,9 @@ export const getJSDocData = ( analyzer: AnalyzerInterface ) => { const events = new Map(); - const slots = new Map(); - const cssProperties = new Map(); - const cssParts = new Map(); + const slots = new Map(); + const cssProperties = new Map(); + const cssParts = new Map(); const jsDocTags = ts.getJSDocTags(node); if (jsDocTags !== undefined) { for (const tag of jsDocTags) { @@ -77,7 +78,7 @@ export const getJSDocData = ( } } return { - ...parseNodeJSDocInfo(node, analyzer), + ...parseNodeJSDocInfo(node), events, slots, cssProperties, @@ -90,10 +91,10 @@ export const getJSDocData = ( * provided map. */ const addNamedJSDocInfoToMap = ( - map: Map, + map: Map, tag: ts.JSDocTag ) => { - const info = parseNameDescSummary(tag); + const info = parseNamedJSDocInfo(tag); if (info !== undefined) { map.set(info.name, info); } diff --git a/packages/labs/analyzer/src/lib/lit-element/properties.ts b/packages/labs/analyzer/src/lib/lit-element/properties.ts index 9ce53cd293..226f777ce3 100644 --- a/packages/labs/analyzer/src/lib/lit-element/properties.ts +++ b/packages/labs/analyzer/src/lib/lit-element/properties.ts @@ -16,9 +16,7 @@ import {ReactiveProperty, AnalyzerInterface} from '../model.js'; import {getTypeForNode} from '../types.js'; import {getPropertyDecorator, getPropertyOptions} from './decorators.js'; import {DiagnosticsError} from '../errors.js'; - -const isStatic = (prop: ts.PropertyDeclaration) => - prop.modifiers?.some((mod) => mod.kind === ts.SyntaxKind.StaticKeyword); +import {hasStaticModifier} from '../utils.js'; export const getProperties = ( classDeclaration: LitClassDeclaration, @@ -53,13 +51,13 @@ export const getProperties = ( reflect: getPropertyReflect(options), converter: getPropertyConverter(options), }); - } else if (name === 'properties' && isStatic(prop)) { + } else if (name === 'properties' && hasStaticModifier(prop)) { // This field has the static properties block (initializer or getter). // Note we will process this after the loop so that the // `undecoratedProperties` map is complete before processing the static // properties block. staticProperties = prop; - } else if (!isStatic(prop)) { + } else if (!hasStaticModifier(prop)) { // Store the declaration node for any undecorated properties. In a TS // program that happens to use a static properties block along with // the `declare` keyword to type the field, we can use this node to diff --git a/packages/labs/analyzer/src/lib/model.ts b/packages/labs/analyzer/src/lib/model.ts index cee52cd67c..52afbe0270 100644 --- a/packages/labs/analyzer/src/lib/model.ts +++ b/packages/labs/analyzer/src/lib/model.ts @@ -78,7 +78,7 @@ export type LocalNameOrReference = string | Reference; export type ExportMap = Map; export type DeclarationMap = Map Declaration)>; -export interface ModuleInit { +export interface ModuleInit extends DeprecatableDescribed { sourceFile: ts.SourceFile; sourcePath: PackagePath; jsPath: PackagePath; @@ -137,6 +137,18 @@ export class Module { * A list of module paths for all wildcard re-exports */ private _finalizeExports: (() => void) | undefined; + /** + * The module's user-facing description. + */ + readonly description?: string | undefined; + /** + * The module's user-facing summary. + */ + readonly summary?: string | undefined; + /** + * The module's user-facing deprecation status. + */ + readonly deprecated?: string | boolean | undefined; constructor(init: ModuleInit) { this.sourceFile = init.sourceFile; @@ -147,6 +159,9 @@ export class Module { this.dependencies = init.dependencies; this._exportMap = init.exportMap; this._finalizeExports = init.finalizeExports; + this.description = init.description; + this.summary = init.summary; + this.deprecated = init.deprecated; } /** @@ -255,7 +270,7 @@ export class Module { } } -interface DeclarationInit extends NodeJSDocInfo { +interface DeclarationInit extends DeprecatableDescribed { name: string; } @@ -279,15 +294,27 @@ export abstract class Declaration { isLitElementDeclaration(): this is LitElementDeclaration { return this instanceof LitElementDeclaration; } + isFunctionDeclaration(): this is FunctionDeclaration { + return this instanceof FunctionDeclaration; + } + isClassField(): this is ClassField { + return this instanceof ClassField; + } + isClassMethod(): this is ClassMethod { + return this instanceof ClassMethod; + } } export interface VariableDeclarationInit extends DeclarationInit { - node: ts.VariableDeclaration | ts.ExportAssignment; + node: ts.VariableDeclaration | ts.ExportAssignment | ts.EnumDeclaration; type: Type | undefined; } export class VariableDeclaration extends Declaration { - readonly node: ts.VariableDeclaration | ts.ExportAssignment; + readonly node: + | ts.VariableDeclaration + | ts.ExportAssignment + | ts.EnumDeclaration; readonly type: Type | undefined; constructor(init: VariableDeclarationInit) { super(init); @@ -296,6 +323,78 @@ export class VariableDeclaration extends Declaration { } } +export interface FunctionLikeInit extends DeprecatableDescribed { + name: string; + parameters?: Parameter[] | undefined; + return?: Return | undefined; +} + +export class FunctionDeclaration extends Declaration { + parameters?: Parameter[] | undefined; + return?: Return | undefined; + constructor(init: FunctionLikeInit) { + super(init); + this.parameters = init.parameters; + this.return = init.return; + } +} + +export type Privacy = 'public' | 'private' | 'protected'; + +export interface SourceReference { + href: string; +} + +export interface ClassMethodInit extends FunctionLikeInit { + static?: boolean | undefined; + privacy?: Privacy | undefined; + inheritedFrom?: Reference | undefined; + source?: SourceReference | undefined; +} + +export class ClassMethod extends Declaration { + static?: boolean | undefined; + privacy?: Privacy | undefined; + inheritedFrom?: Reference | undefined; + source?: SourceReference | undefined; + parameters?: Parameter[] | undefined; + return?: Return | undefined; + constructor(init: ClassMethodInit) { + super(init); + this.static = init.static; + this.privacy = init.privacy; + this.inheritedFrom = init.inheritedFrom; + this.source = init.source; + this.parameters = init.parameters; + this.return = init.return; + } +} + +export interface ClassFieldInit extends PropertyLike { + static?: boolean | undefined; + privacy?: Privacy | undefined; + inheritedFrom?: Reference | undefined; + source?: SourceReference | undefined; +} + +export class ClassField extends Declaration { + static?: boolean | undefined; + privacy?: Privacy | undefined; + inheritedFrom?: Reference | undefined; + source?: SourceReference | undefined; + type?: Type | undefined; + default?: string | undefined; + constructor(init: ClassFieldInit) { + super(init); + this.static = init.static; + this.privacy = init.privacy; + this.inheritedFrom = init.inheritedFrom; + this.source = init.source; + this.type = init.type; + this.default = init.default; + } +} + export type ClassHeritage = { mixins: Reference[]; superClass: Reference | undefined; @@ -304,33 +403,93 @@ export type ClassHeritage = { export interface ClassDeclarationInit extends DeclarationInit { node: ts.ClassDeclaration; getHeritage: () => ClassHeritage; + fieldMap?: Map | undefined; + methodMap?: Map | undefined; } export class ClassDeclaration extends Declaration { readonly node: ts.ClassDeclaration; private _getHeritage: () => ClassHeritage; private _heritage: ClassHeritage | undefined = undefined; + readonly _fieldMap: Map; + readonly _methodMap: Map; constructor(init: ClassDeclarationInit) { super(init); this.node = init.node; this._getHeritage = init.getHeritage; + this._fieldMap = init.fieldMap ?? new Map(); + this._methodMap = init.methodMap ?? new Map(); } + /** + * Returns this class's `ClassHeritage` model, with references to its + * `superClass` and `mixins`. + */ get heritage(): ClassHeritage { return (this._heritage ??= this._getHeritage()); } + + /** + * Returns iterator of the `ClassField`s defined on the immediate class + * (excluding any inherited members). + */ + get fields() { + return this._fieldMap.values(); + } + + /** + * Returns iterator of the `ClassMethod`s defined on the immediate class + * (excluding any inherited members). + */ + get methods(): IterableIterator { + return this._methodMap.values(); + } + + /** + * Returns a `ClassField` model the given name defined on the immediate class + * (excluding any inherited members). + */ + getField(name: string): ClassField | undefined { + return this._fieldMap.get(name); + } + + /** + * Returns a `ClassMethod` model for the given name defined on the immediate + * class (excluding any inherited members). + */ + getMethod(name: string): ClassMethod | undefined { + return this._methodMap.get(name); + } + + /** + * Returns a `ClassField` or `ClassMethod` model for the given name defined on + * the immediate class (excluding any inherited members). + * + * Note that if a field and method of the same name were defined (error is TS, + * but possible in JS), the `ClassField` will be returned from this method, as + * it takes precedence by virtue of being an instance property (vs. a method, + * which is defined on the prototype). + */ + getMember(name: string): ClassMethod | ClassField | undefined { + return this.getField(name) ?? this.getMethod(name); + } } -export interface NamedJSDocInfo { - name: string; +export interface Described { description?: string | undefined; summary?: string | undefined; } -export interface NodeJSDocInfo { - description?: string | undefined; - summary?: string | undefined; +export interface NamedDescribed extends Described { + name: string; +} + +export interface TypedNamedDescribed extends NamedDescribed { + type?: string; +} + +export interface DeprecatableDescribed extends Described { deprecated?: string | boolean | undefined; } @@ -338,9 +497,9 @@ interface LitElementDeclarationInit extends ClassDeclarationInit { tagname: string | undefined; reactiveProperties: Map; events: Map; - slots: Map; - cssProperties: Map; - cssParts: Map; + slots: Map; + cssProperties: Map; + cssParts: Map; } export class LitElementDeclaration extends ClassDeclaration { @@ -357,9 +516,9 @@ export class LitElementDeclaration extends ClassDeclaration { readonly reactiveProperties: Map; readonly events: Map; - readonly slots: Map; - readonly cssProperties: Map; - readonly cssParts: Map; + readonly slots: Map; + readonly cssProperties: Map; + readonly cssParts: Map; constructor(init: LitElementDeclarationInit) { super(init); @@ -379,11 +538,24 @@ export interface LitElementExport extends LitElementDeclaration { tagname: string; } -export interface ReactiveProperty { +export interface PropertyLike extends DeprecatableDescribed { name: string; - type: Type | undefined; + default?: string | undefined; +} + +export interface Return { + type?: Type | undefined; + summary?: string | undefined; + description?: string | undefined; +} + +export interface Parameter extends PropertyLike { + optional?: boolean | undefined; + rest?: boolean | undefined; +} +export interface ReactiveProperty extends PropertyLike { reflect: boolean; // TODO(justinfagnani): should we convert into attribute name? @@ -411,6 +583,7 @@ export interface ReactiveProperty { export interface Event { name: string; description: string | undefined; + summary: string | undefined; type: Type | undefined; } diff --git a/packages/labs/analyzer/src/lib/references.ts b/packages/labs/analyzer/src/lib/references.ts index 046dbad338..072e0fa3f0 100644 --- a/packages/labs/analyzer/src/lib/references.ts +++ b/packages/labs/analyzer/src/lib/references.ts @@ -35,12 +35,6 @@ export const getSymbolForName = ( .filter((s) => s.name === name)[0]; }; -/** - * Returns if the given declaration is exported from the module or not. - */ -export const hasExportKeyword = (node: ts.Statement) => - !!node.modifiers?.find((m) => m.kind === ts.SyntaxKind.ExportKeyword); - interface ModuleSpecifierInfo { specifier: string; location: ts.Node; diff --git a/packages/labs/analyzer/src/lib/types.ts b/packages/labs/analyzer/src/lib/types.ts index 29c27bc54c..67fd1c347a 100644 --- a/packages/labs/analyzer/src/lib/types.ts +++ b/packages/labs/analyzer/src/lib/types.ts @@ -21,23 +21,21 @@ import { } from './references.js'; /** - * Returns an analyzer `Type` object for the given jsDoc tag. + * Returns an analyzer `Type` object for the given type string, + * evaluated at the given location. * - * Note, the tag type must + * Used for parsing types from JSDoc. */ -export const getTypeForJSDocTag = ( - tag: ts.JSDocTag, +export const getTypeForTypeString = ( + typeString: string, + location: ts.Node, analyzer: AnalyzerInterface ): Type | undefined => { - const typeString = - ts.isJSDocUnknownTag(tag) && typeof tag.comment === 'string' - ? tag.comment?.match(/{(?.*)}/)?.groups?.type - : undefined; if (typeString !== undefined) { const typeNode = parseType(typeString); if (typeNode == undefined) { throw new DiagnosticsError( - tag, + location, `Internal error: failed to parse type from JSDoc comment.` ); } @@ -47,7 +45,8 @@ export const getTypeForJSDocTag = ( return new Type({ type, text: typeString, - getReferences: () => getReferencesForTypeNode(typeNode, tag, analyzer), + getReferences: () => + getReferencesForTypeNode(typeNode, location, analyzer), }); } else { return undefined; @@ -77,7 +76,7 @@ export const getTypeForNode = ( * Converts a ts.Type into an analyzer Type object (which wraps * the ts.Type, but also provides analyzer Reference objects). */ -const getTypeForType = ( +export const getTypeForType = ( type: ts.Type, location: ts.Node, analyzer: AnalyzerInterface diff --git a/packages/labs/analyzer/src/lib/utils.ts b/packages/labs/analyzer/src/lib/utils.ts new file mode 100644 index 0000000000..44537dcfe5 --- /dev/null +++ b/packages/labs/analyzer/src/lib/utils.ts @@ -0,0 +1,55 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +/** + * @fileoverview + * + * Helper utilities for analyzing declarations + */ + +import ts from 'typescript'; +import {hasJSDocTag} from './javascript/jsdoc.js'; +import {Privacy} from './model.js'; + +export const hasModifier = (node: ts.Node, modifier: ts.SyntaxKind) => { +return node.modifiers?.some((s) => s.kind === modifier) ?? false; +}; + +export const hasExportModifier = (node: ts.Node) => { + return hasModifier(node, ts.SyntaxKind.ExportKeyword); +}; + +export const hasDefaultModifier = (node: ts.Node) => { + return hasModifier(node, ts.SyntaxKind.DefaultKeyword); +}; + +export const hasStaticModifier = (node: ts.Node) => { + return hasModifier(node, ts.SyntaxKind.StaticKeyword); +}; + +export const hasPrivateModifier = (node: ts.Node) => { + return hasModifier(node, ts.SyntaxKind.PrivateKeyword); +}; + +export const hasProtectedModifier = (node: ts.Node) => { + return hasModifier(node, ts.SyntaxKind.ProtectedKeyword); +}; + +const isPrivate = (node: ts.Node) => { + return hasPrivateModifier(node) || hasJSDocTag(node, 'private'); +}; + +const isProtected = (node: ts.Node) => { + return hasProtectedModifier(node) || hasJSDocTag(node, 'protected'); +}; + +export const getPrivacy = (node: ts.Node): Privacy => { + return isPrivate(node) + ? 'private' + : isProtected(node) + ? 'protected' + : 'public'; +}; diff --git a/packages/labs/analyzer/src/test/lit-element/events_test.ts b/packages/labs/analyzer/src/test/lit-element/events_test.ts index 5177b146a6..f9c0a7b443 100644 --- a/packages/labs/analyzer/src/test/lit-element/events_test.ts +++ b/packages/labs/analyzer/src/test/lit-element/events_test.ts @@ -111,6 +111,7 @@ for (const lang of languages) { ); assert.equal(event.type?.references[0].module, 'element-a.js'); assert.equal(event.type?.references[0].name, 'LocalCustomEvent'); + assert.equal(event.description, 'Local custom event'); }); test('Event with imported custom event type', ({element}) => { @@ -123,6 +124,7 @@ for (const lang of languages) { ); assert.equal(event.type?.references[0].module, 'custom-event.js'); assert.equal(event.type?.references[0].name, 'ExternalCustomEvent'); + assert.equal(event.description, 'External custom event'); }); test('Event with generic custom event type', ({element}) => { @@ -137,6 +139,7 @@ for (const lang of languages) { ); assert.equal(event.type?.references[1].module, 'custom-event.js'); assert.equal(event.type?.references[1].name, 'ExternalClass'); + assert.equal(event.description, 'Generic custom event'); }); test('Event with custom event type with inline detail', ({element}) => { @@ -156,6 +159,7 @@ for (const lang of languages) { ); assert.equal(event.type?.references[2].module, 'custom-event.js'); assert.equal(event.type?.references[2].name, 'ExternalClass'); + assert.equal(event.description, 'Inline\ndetail custom event description'); }); test.run(); diff --git a/packages/labs/analyzer/src/test/lit-element/jsdoc_test.ts b/packages/labs/analyzer/src/test/lit-element/jsdoc_test.ts index da5fcc9187..e88379b69a 100644 --- a/packages/labs/analyzer/src/test/lit-element/jsdoc_test.ts +++ b/packages/labs/analyzer/src/test/lit-element/jsdoc_test.ts @@ -8,7 +8,7 @@ import {suite} from 'uvu'; // eslint-disable-next-line import/extensions import * as assert from 'uvu/assert'; import {fileURLToPath} from 'url'; -import {getSourceFilename, languages} from '../utils.js'; +import {getSourceFilename, InMemoryAnalyzer, languages} from '../utils.js'; import {createPackageAnalyzer, Module, AbsolutePath} from '../../index.js'; @@ -43,110 +43,77 @@ for (const lang of languages) { test('slots - Correct number found', ({getModule}) => { const element = getModule('element-a').getDeclaration('ElementA'); assert.ok(element.isLitElementDeclaration()); - assert.equal(element.slots.size, 5); + assert.equal(element.slots.size, 4); }); - test('slots - basic', ({getModule}) => { + test('slots - no-description', ({getModule}) => { const element = getModule('element-a').getDeclaration('ElementA'); assert.ok(element.isLitElementDeclaration()); - const slot = element.slots.get('basic'); + const slot = element.slots.get('no-description'); assert.ok(slot); assert.equal(slot.summary, undefined); assert.equal(slot.description, undefined); }); - test('slots - with-summary', ({getModule}) => { - const element = getModule('element-a').getDeclaration('ElementA'); - assert.ok(element.isLitElementDeclaration()); - const slot = element.slots.get('with-summary'); - assert.ok(slot); - assert.equal(slot.summary, 'Summary for with-summary'); - assert.equal(slot.description, undefined); - }); - - test('slots - with-summary-dash', ({getModule}) => { - const element = getModule('element-a').getDeclaration('ElementA'); - assert.ok(element.isLitElementDeclaration()); - const slot = element.slots.get('with-summary-dash'); - assert.ok(slot); - assert.equal(slot.summary, 'Summary for with-summary-dash'); - assert.equal(slot.description, undefined); - }); - - test('slots - with-summary-colon', ({getModule}) => { - const element = getModule('element-a').getDeclaration('ElementA'); - assert.ok(element.isLitElementDeclaration()); - const slot = element.slots.get('with-summary-colon'); - assert.ok(slot); - assert.equal(slot.summary, 'Summary for with-summary-colon'); - assert.equal(slot.description, undefined); - }); - test('slots - with-description', ({getModule}) => { const element = getModule('element-a').getDeclaration('ElementA'); assert.ok(element.isLitElementDeclaration()); const slot = element.slots.get('with-description'); assert.ok(slot); - assert.equal(slot.summary, 'Summary for with-description'); + assert.equal(slot.summary, undefined); assert.equal( slot.description, - 'Description for with-description\nMore description for with-description\n\nEven more description for with-description' + 'Description for with-description\nwith wraparound' ); }); - // cssParts - - test('cssParts - Correct number found', ({getModule}) => { + test('slots - with-description-dash', ({getModule}) => { const element = getModule('element-a').getDeclaration('ElementA'); assert.ok(element.isLitElementDeclaration()); - assert.equal(element.cssParts.size, 5); + const slot = element.slots.get('with-description-dash'); + assert.ok(slot); + assert.equal(slot.summary, undefined); + assert.equal(slot.description, 'Description for with-description-dash'); }); - test('cssParts - basic', ({getModule}) => { - const element = getModule('element-a').getDeclaration('ElementA'); - assert.ok(element.isLitElementDeclaration()); - const part = element.cssParts.get('basic'); - assert.ok(part); - assert.equal(part.summary, undefined); - assert.equal(part.description, undefined); - }); + // cssParts - test('cssParts - with-summary', ({getModule}) => { + test('cssParts - Correct number found', ({getModule}) => { const element = getModule('element-a').getDeclaration('ElementA'); assert.ok(element.isLitElementDeclaration()); - const part = element.cssParts.get('with-summary'); - assert.ok(part); - assert.equal(part.summary, 'Summary for :part(with-summary)'); - assert.equal(part.description, undefined); + assert.equal(element.cssParts.size, 3); }); - test('cssParts - with-summary-dash', ({getModule}) => { + test('cssParts - no-description', ({getModule}) => { const element = getModule('element-a').getDeclaration('ElementA'); assert.ok(element.isLitElementDeclaration()); - const part = element.cssParts.get('with-summary-dash'); + const part = element.cssParts.get('no-description'); assert.ok(part); - assert.equal(part.summary, 'Summary for :part(with-summary-dash)'); + assert.equal(part.summary, undefined); assert.equal(part.description, undefined); }); - test('cssParts - with-summary-colon', ({getModule}) => { + test('cssParts - with-description', ({getModule}) => { const element = getModule('element-a').getDeclaration('ElementA'); assert.ok(element.isLitElementDeclaration()); - const part = element.cssParts.get('with-summary-colon'); + const part = element.cssParts.get('with-description'); assert.ok(part); - assert.equal(part.summary, 'Summary for :part(with-summary-colon)'); - assert.equal(part.description, undefined); + assert.equal(part.summary, undefined); + assert.equal( + part.description, + 'Description for :part(with-description)\nwith wraparound' + ); }); - test('cssParts - with-description', ({getModule}) => { + test('cssParts - with-description-dash', ({getModule}) => { const element = getModule('element-a').getDeclaration('ElementA'); assert.ok(element.isLitElementDeclaration()); - const part = element.cssParts.get('with-description'); + const part = element.cssParts.get('with-description-dash'); assert.ok(part); - assert.equal(part.summary, 'Summary for :part(with-description)'); + assert.equal(part.summary, undefined); assert.equal( part.description, - 'Description for :part(with-description)\nMore description for :part(with-description)\n\nEven more description for :part(with-description)' + 'Description for :part(with-description-dash)' ); }); @@ -155,106 +122,73 @@ for (const lang of languages) { test('cssProperties - Correct number found', ({getModule}) => { const element = getModule('element-a').getDeclaration('ElementA'); assert.ok(element.isLitElementDeclaration()); - assert.equal(element.cssProperties.size, 10); + assert.equal(element.cssProperties.size, 6); }); - test('cssProperties - basic', ({getModule}) => { + test('cssProperties - no-description', ({getModule}) => { const element = getModule('element-a').getDeclaration('ElementA'); assert.ok(element.isLitElementDeclaration()); - const prop = element.cssProperties.get('--basic'); + const prop = element.cssProperties.get('--no-description'); assert.ok(prop); assert.equal(prop.summary, undefined); assert.equal(prop.description, undefined); }); - test('cssProperties - with-summary', ({getModule}) => { - const element = getModule('element-a').getDeclaration('ElementA'); - assert.ok(element.isLitElementDeclaration()); - const prop = element.cssProperties.get('--with-summary'); - assert.ok(prop); - assert.equal(prop.summary, 'Summary for --with-summary'); - assert.equal(prop.description, undefined); - }); - - test('cssProperties - with-summary-colon', ({getModule}) => { - const element = getModule('element-a').getDeclaration('ElementA'); - assert.ok(element.isLitElementDeclaration()); - const prop = element.cssProperties.get('--with-summary-colon'); - assert.ok(prop); - assert.equal(prop.summary, 'Summary for --with-summary-colon'); - assert.equal(prop.description, undefined); - }); - - test('cssProperties - with-summary-dash', ({getModule}) => { - const element = getModule('element-a').getDeclaration('ElementA'); - assert.ok(element.isLitElementDeclaration()); - const prop = element.cssProperties.get('--with-summary-dash'); - assert.ok(prop); - assert.equal(prop.summary, 'Summary for --with-summary-dash'); - assert.equal(prop.description, undefined); - }); - test('cssProperties - with-description', ({getModule}) => { const element = getModule('element-a').getDeclaration('ElementA'); assert.ok(element.isLitElementDeclaration()); const prop = element.cssProperties.get('--with-description'); assert.ok(prop); - assert.equal(prop.summary, 'Summary for --with-description'); + assert.equal(prop.summary, undefined); assert.equal( prop.description, - 'Description for --with-description\nMore description for --with-description\n\nEven more description for --with-description' + 'Description for --with-description\nwith wraparound' ); }); - test('cssProperties - short-basic', ({getModule}) => { + test('cssProperties - with-description-dash', ({getModule}) => { const element = getModule('element-a').getDeclaration('ElementA'); assert.ok(element.isLitElementDeclaration()); - const prop = element.cssProperties.get('--short-basic'); + const prop = element.cssProperties.get('--with-description-dash'); assert.ok(prop); assert.equal(prop.summary, undefined); - assert.equal(prop.description, undefined); + assert.equal(prop.description, 'Description for --with-description-dash'); }); - test('cssProperties - short-with-summary', ({getModule}) => { + test('cssProperties - short-no-description', ({getModule}) => { const element = getModule('element-a').getDeclaration('ElementA'); assert.ok(element.isLitElementDeclaration()); - const prop = element.cssProperties.get('--short-with-summary'); + const prop = element.cssProperties.get('--short-no-description'); assert.ok(prop); - assert.equal(prop.summary, 'Summary for --short-with-summary'); - assert.equal(prop.description, undefined); - }); - - test('cssProperties - short-with-summary-colon', ({getModule}) => { - const element = getModule('element-a').getDeclaration('ElementA'); - assert.ok(element.isLitElementDeclaration()); - const prop = element.cssProperties.get('--short-with-summary-colon'); - assert.ok(prop); - assert.equal(prop.summary, 'Summary for --short-with-summary-colon'); + assert.equal(prop.summary, undefined); assert.equal(prop.description, undefined); }); - test('cssProperties - short-with-summary-dash', ({getModule}) => { + test('cssProperties - short-with-description', ({getModule}) => { const element = getModule('element-a').getDeclaration('ElementA'); assert.ok(element.isLitElementDeclaration()); - const prop = element.cssProperties.get('--short-with-summary-dash'); + const prop = element.cssProperties.get('--short-with-description'); assert.ok(prop); - assert.equal(prop.summary, 'Summary for --short-with-summary-dash'); - assert.equal(prop.description, undefined); + assert.equal(prop.summary, undefined); + assert.equal( + prop.description, + 'Description for --short-with-description\nwith wraparound' + ); }); - test('cssProperties - short-with-description', ({getModule}) => { + test('cssProperties - short-with-description-dash', ({getModule}) => { const element = getModule('element-a').getDeclaration('ElementA'); assert.ok(element.isLitElementDeclaration()); - const prop = element.cssProperties.get('--short-with-description'); + const prop = element.cssProperties.get('--short-with-description-dash'); assert.ok(prop); - assert.equal(prop.summary, 'Summary for --short-with-description'); + assert.equal(prop.summary, undefined); assert.equal( prop.description, - 'Description for --short-with-description\nMore description for --short-with-description\n\nEven more description for --short-with-description' + 'Description for --short-with-description-dash' ); }); - // description, summary, deprecated + // Class description, summary, deprecated test('tagged description and summary', ({getModule}) => { const element = getModule('element-a').getDeclaration('TaggedDescription'); @@ -286,21 +220,373 @@ nisi ut aliquip ex ea commodo consequat.` assert.equal(element.deprecated, `UntaggedDescription deprecated message.`); }); - test('untagged description and summary', ({getModule}) => { - const element = getModule('element-a').getDeclaration( - 'UntaggedDescSummary' + // Fields + + test('field1', ({getModule}) => { + const element = getModule('element-a').getDeclaration('ElementA'); + assert.ok(element.isClassDeclaration()); + const member = element.getField('field1'); + assert.ok(member?.isClassField()); + assert.equal( + member.description, + `Class field 1 description\nwith wraparound` ); - assert.ok(element.isLitElementDeclaration()); + assert.equal(member.default, `'default1'`); + assert.equal(member.privacy, 'private'); + assert.equal(member.type?.text, 'string'); + }); + + test('field2', ({getModule}) => { + const element = getModule('element-a').getDeclaration('ElementA'); + assert.ok(element.isClassDeclaration()); + const member = element.getField('field2'); + assert.ok(member?.isClassField()); + assert.equal(member.summary, `Class field 2 summary\nwith wraparound`); assert.equal( - element.description, - `UntaggedDescSummary description. Lorem ipsum dolor sit amet, consectetur -adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna -aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris -nisi ut aliquip ex ea commodo consequat.` + member.description, + `Class field 2 description\nwith wraparound` + ); + assert.equal(member.default, undefined); + assert.equal(member.privacy, 'protected'); + assert.equal(member.type?.text, 'string | number'); + }); + + test('field3', ({getModule}) => { + const element = getModule('element-a').getDeclaration('ElementA'); + assert.ok(element.isClassDeclaration()); + const member = element.getField('field3'); + assert.ok(member?.isClassField()); + assert.equal( + member.description, + `Class field 3 description\nwith wraparound` + ); + assert.equal(member.default, undefined); + assert.equal(member.privacy, 'public'); + assert.equal(member.type?.text, 'string'); + assert.equal(member.deprecated, true); + }); + + test('field4', ({getModule}) => { + const element = getModule('element-a').getDeclaration('ElementA'); + assert.ok(element.isClassDeclaration()); + const member = element.getField('field4'); + assert.ok(member?.isClassField()); + assert.equal(member.summary, `Class field 4 summary\nwith wraparound`); + assert.equal( + member.description, + `Class field 4 description\nwith wraparound` + ); + assert.equal( + member.default, + `new Promise${lang === 'ts' ? '' : ''}((r) => r())` + ); + assert.equal(member.type?.text, 'Promise'); + assert.equal(member.deprecated, 'Class field 4 deprecated'); + }); + + // Methods + + test('method1', ({getModule}) => { + const element = getModule('element-a').getDeclaration('ElementA'); + assert.ok(element.isClassDeclaration()); + const member = element.getMethod('method1'); + assert.ok(member?.isClassMethod()); + assert.equal(member.description, `Method 1 description\nwith wraparound`); + assert.equal(member.parameters?.length, 0); + assert.equal(member.return?.type?.text, 'void'); + }); + + test('method2', ({getModule}) => { + const element = getModule('element-a').getDeclaration('ElementA'); + assert.ok(element.isClassDeclaration()); + const member = element.getMethod('method2'); + assert.ok(member?.isClassMethod()); + assert.equal(member.summary, `Method 2 summary\nwith wraparound`); + assert.equal(member.description, `Method 2 description\nwith wraparound`); + assert.equal(member.parameters?.length, 3); + assert.equal(member.parameters?.[0].name, 'a'); + assert.equal(member.parameters?.[0].description, 'Param a description'); + assert.equal(member.parameters?.[0].summary, undefined); + assert.equal(member.parameters?.[0].type?.text, 'string'); + assert.equal(member.parameters?.[0].default, undefined); + assert.equal(member.parameters?.[0].rest, false); + assert.equal(member.parameters?.[1].name, 'b'); + assert.equal( + member.parameters?.[1].description, + 'Param b description\nwith wraparound' ); - assert.equal(element.summary, `UntaggedDescSummary summary.`); - assert.equal(element.deprecated, true); + assert.equal(member.parameters?.[1].type?.text, 'boolean'); + assert.equal(member.parameters?.[1].optional, true); + assert.equal(member.parameters?.[1].default, 'false'); + assert.equal(member.parameters?.[1].rest, false); + assert.equal(member.parameters?.[2].name, 'c'); + assert.equal(member.parameters?.[2].description, 'Param c description'); + assert.equal(member.parameters?.[2].summary, undefined); + assert.equal(member.parameters?.[2].type?.text, 'number[]'); + assert.equal(member.parameters?.[2].optional, false); + assert.equal(member.parameters?.[2].default, undefined); + assert.equal(member.parameters?.[2].rest, true); + assert.equal(member.return?.type?.text, 'string'); + assert.equal(member.return?.description, 'Method 2 return description'); + assert.equal(member.deprecated, 'Method 2 deprecated'); }); test.run(); + + // Doing module JSDoc tests in-memory, to test a number of variations + // without needing to maintain a file for each. + + for (const hasFirstStatementDoc of [false, true]) { + const moduleTest = suite<{ + analyzer: InMemoryAnalyzer; + }>( + `Module jsDoc tests, ${ + hasFirstStatementDoc ? 'has' : 'no' + } first statement docs (${lang})` + ); + + moduleTest.before.each((ctx) => { + ctx.analyzer = new InMemoryAnalyzer(lang, { + '/package.json': JSON.stringify({name: '@lit-internal/in-memory-test'}), + }); + }); + + const firstStatementDoc = hasFirstStatementDoc + ? ` + /** + * First statement description + * @summary First statement summary + */ + ` + : ''; + + moduleTest('untagged module description with @module tag', ({analyzer}) => { + analyzer.setFile( + '/module', + ` + /** + * Module description + * more description + * @module + */ + ${firstStatementDoc} + export const foo = 42; + ` + ); + const module = analyzer.getModule( + getSourceFilename('/module', lang) as AbsolutePath + ); + assert.equal(module.description, 'Module description\nmore description'); + }); + + moduleTest( + 'untagged module description with @fileoverview tag', + ({analyzer}) => { + analyzer.setFile( + '/module', + ` + /** + * Module description + * more description + * @fileoverview + */ + ${firstStatementDoc} + export const foo = 42; + ` + ); + const module = analyzer.getModule( + getSourceFilename('/module', lang) as AbsolutePath + ); + assert.equal( + module.description, + 'Module description\nmore description' + ); + } + ); + + moduleTest('module description in @fileoverview tag', ({analyzer}) => { + analyzer.setFile( + '/module', + ` + /** + * @fileoverview Module description + * more description + */ + ${firstStatementDoc} + export const foo = 42; + ` + ); + const module = analyzer.getModule( + getSourceFilename('/module', lang) as AbsolutePath + ); + assert.equal(module.description, 'Module description\nmore description'); + }); + + moduleTest( + 'untagged module description with @packageDocumentation tag', + ({analyzer}) => { + analyzer.setFile( + '/module', + ` + /** + * Module description + * more description + * @packageDocumentation + */ + ${firstStatementDoc} + export const foo = 42; + ` + ); + const module = analyzer.getModule( + getSourceFilename('/module', lang) as AbsolutePath + ); + assert.equal( + module.description, + 'Module description\nmore description' + ); + } + ); + + moduleTest( + 'module description in @packageDocumentation tag', + ({analyzer}) => { + analyzer.setFile( + '/module', + ` + /** + * @packageDocumentation Module description + * more description + */ + ${firstStatementDoc} + export const foo = 42; + ` + ); + const module = analyzer.getModule( + getSourceFilename('/module', lang) as AbsolutePath + ); + assert.equal( + module.description, + 'Module description\nmore description' + ); + } + ); + + moduleTest( + 'module description in @packageDocumentation tag with other tags', + ({analyzer}) => { + analyzer.setFile( + '/module', + ` + /** + * @packageDocumentation Module description + * more description + * @module foo + * @deprecated Module is deprecated + */ + ${firstStatementDoc} + export const foo = 42; + ` + ); + const module = analyzer.getModule( + getSourceFilename('/module', lang) as AbsolutePath + ); + assert.equal( + module.description, + 'Module description\nmore description' + ); + assert.equal(module.deprecated, 'Module is deprecated'); + } + ); + + moduleTest('untagged module description', ({analyzer}) => { + analyzer.setFile( + '/module', + ` + /** + * Module description + * more module description + * @summary Module summary + * @deprecated + */ + /** + * First statement description + * @summary First statement summary + */ + export const foo = 42; + ` + ); + const module = analyzer.getModule( + getSourceFilename('/module', lang) as AbsolutePath + ); + assert.equal( + module.description, + 'Module description\nmore module description' + ); + assert.equal(module.summary, 'Module summary'); + assert.equal(module.deprecated, true); + }); + + moduleTest('multiple untagged module descriptions', ({analyzer}) => { + analyzer.setFile( + '/module', + ` + /** + * Module description + * more module description + */ + /** + * Even more module description + */ + /** + * First statement description + * @summary First statement summary + */ + export const foo = 42; + ` + ); + const module = analyzer.getModule( + getSourceFilename('/module', lang) as AbsolutePath + ); + assert.equal( + module.description, + 'Module description\nmore module description\nEven more module description' + ); + }); + + moduleTest( + 'multiple untagged module descriptions with other tags', + ({analyzer}) => { + analyzer.setFile( + '/module', + ` + /** + * Module description + * more module description + * @deprecated + */ + /** + * Even more module description + * @summary Module summary + */ + /** + * First statement description + * @summary First statement summary + */ + export const foo = 42; + ` + ); + const module = analyzer.getModule( + getSourceFilename('/module', lang) as AbsolutePath + ); + assert.equal( + module.description, + 'Module description\nmore module description\nEven more module description' + ); + assert.equal(module.summary, 'Module summary'); + assert.equal(module.deprecated, true); + } + ); + + moduleTest.run(); + } } diff --git a/packages/labs/analyzer/test-files/js/events/element-a.js b/packages/labs/analyzer/test-files/js/events/element-a.js index 7000ae17b3..bbd1627d74 100644 --- a/packages/labs/analyzer/test-files/js/events/element-a.js +++ b/packages/labs/analyzer/test-files/js/events/element-a.js @@ -24,9 +24,9 @@ export class LocalCustomEvent extends Event { * @fires typed-event-three {MouseEvent} - This is another typed event * @fires external-custom-event {ExternalCustomEvent} - External custom event * @fires local-custom-event {LocalCustomEvent} - Local custom event - * @fires generic-custom-event {CustomEvent} - Local custom event - * @fires inline-detail-custom-event {CustomEvent<{ event: MouseEvent; more: { impl: ExternalClass; }; }>} - * + * @fires generic-custom-event {CustomEvent} - Generic custom event + * @fires inline-detail-custom-event {CustomEvent<{ event: MouseEvent; more: { impl: ExternalClass; }; }>} Inline + * detail custom event description * @comment malformed fires tag: * * @fires diff --git a/packages/labs/analyzer/test-files/js/jsdoc/element-a.js b/packages/labs/analyzer/test-files/js/jsdoc/element-a.js index 20e64dcd08..b59ccc40ec 100644 --- a/packages/labs/analyzer/test-files/js/jsdoc/element-a.js +++ b/packages/labs/analyzer/test-files/js/jsdoc/element-a.js @@ -9,47 +9,88 @@ import {LitElement} from 'lit'; /** * A cool custom element. * - * @slot basic - * @slot with-summary Summary for with-summary - * @slot with-summary-dash - Summary for with-summary-dash - * @slot with-summary-colon: Summary for with-summary-colon - * @slot with-description - Summary for with-description - * Description for with-description - * More description for with-description - * - * Even more description for with-description - * - * @cssPart basic - * @cssPart with-summary Summary for :part(with-summary) - * @cssPart with-summary-dash - Summary for :part(with-summary-dash) - * @cssPart with-summary-colon: Summary for :part(with-summary-colon) - * @cssPart with-description - Summary for :part(with-description) - * Description for :part(with-description) - * More description for :part(with-description) - * - * Even more description for :part(with-description) - * - * @cssProperty --basic - * @cssProperty --with-summary Summary for --with-summary - * @cssProperty --with-summary-dash - Summary for --with-summary-dash - * @cssProperty --with-summary-colon: Summary for --with-summary-colon - * @cssProperty --with-description - Summary for --with-description - * Description for --with-description - * More description for --with-description - * - * Even more description for --with-description - * - * @cssProp --short-basic - * @cssProp --short-with-summary Summary for --short-with-summary - * @cssProp --short-with-summary-dash - Summary for --short-with-summary-dash - * @cssProp --short-with-summary-colon: Summary for --short-with-summary-colon - * @cssProp --short-with-description - Summary for --short-with-description - * Description for --short-with-description - * More description for --short-with-description - * - * Even more description for --short-with-description + * @slot - Description for default slot + * @slot no-description + * @slot with-description - Description for with-description + * with wraparound + * @slot with-description-dash - Description for with-description-dash + * @cssPart no-description + * @cssPart with-description Description for :part(with-description) + * with wraparound + * @cssPart with-description-dash - Description for :part(with-description-dash) + * @cssProperty --no-description + * @cssProperty --with-description Description for --with-description + * with wraparound + * @cssProperty --with-description-dash - Description for --with-description-dash + * @cssProp --short-no-description + * @cssProp --short-with-description Description for --short-with-description + * with wraparound + * @cssProp --short-with-description-dash - Description for --short-with-description-dash */ -export class ElementA extends LitElement {} +export class ElementA extends LitElement { + /** + * Class field 1 description + * with wraparound + * @private + */ + field1 = 'default1'; + + /** + * @summary Class field 2 summary + * with wraparound + * + * @description Class field 2 description + * with wraparound + * @protected + * @type {number | string} + */ + field2; + + /** + * @description Class field 3 description + * with wraparound + * @optional + * @type {string} + * @deprecated + */ + field3; + + /** + * Class field 4 description + * with wraparound + * @summary Class field 4 summary + * with wraparound + * @type {Promise} + * @deprecated Class field 4 deprecated + */ + field4 = new Promise((r) => r()); + + /** + * Method 1 description + * with wraparound + */ + method1() {} + + /** + * @summary Method 2 summary + * with wraparound + * + * @description Method 2 description + * with wraparound + * + * @param {string} a Param a description + * @param {boolean} b Param b description + * with wraparound + * + * @param {number[]} c Param c description + * @returns {string} Method 2 return description + * + * @deprecated Method 2 deprecated + */ + method2(a, b = false, ...c) { + return b ? a : c[0].toFixed(); + } +} customElements.define('element-a', ElementA); /** @@ -72,15 +113,3 @@ export class TaggedDescription extends LitElement {} * @summary UntaggedDescription summary. */ export class UntaggedDescription extends LitElement {} - -/** - * UntaggedDescSummary summary. - * - * UntaggedDescSummary description. Lorem ipsum dolor sit amet, consectetur - * adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna - * aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris - * nisi ut aliquip ex ea commodo consequat. - * - * @deprecated - */ -export class UntaggedDescSummary extends LitElement {} diff --git a/packages/labs/analyzer/test-files/ts/events/src/element-a.ts b/packages/labs/analyzer/test-files/ts/events/src/element-a.ts index bd8a43131e..2b3b081741 100644 --- a/packages/labs/analyzer/test-files/ts/events/src/element-a.ts +++ b/packages/labs/analyzer/test-files/ts/events/src/element-a.ts @@ -26,9 +26,9 @@ export class LocalCustomEvent extends Event { * @fires typed-event-three {MouseEvent} - This is another typed event * @fires external-custom-event {ExternalCustomEvent} - External custom event * @fires local-custom-event {LocalCustomEvent} - Local custom event - * @fires generic-custom-event {CustomEvent} - Local custom event - * @fires inline-detail-custom-event {CustomEvent<{ event: MouseEvent; more: { impl: ExternalClass; }; }>} - * + * @fires generic-custom-event {CustomEvent} - Generic custom event + * @fires inline-detail-custom-event {CustomEvent<{ event: MouseEvent; more: { impl: ExternalClass; }; }>} Inline + * detail custom event description * @comment malformed fires tag: * * @fires diff --git a/packages/labs/analyzer/test-files/ts/jsdoc/src/element-a.ts b/packages/labs/analyzer/test-files/ts/jsdoc/src/element-a.ts index 5886ebd07a..39a8ad036a 100644 --- a/packages/labs/analyzer/test-files/ts/jsdoc/src/element-a.ts +++ b/packages/labs/analyzer/test-files/ts/jsdoc/src/element-a.ts @@ -10,48 +10,83 @@ import {customElement} from 'lit/decorators.js'; /** * A cool custom element. * - * @slot basic - * @slot with-summary Summary for with-summary - * @slot with-summary-dash - Summary for with-summary-dash - * @slot with-summary-colon: Summary for with-summary-colon - * @slot with-description - Summary for with-description - * Description for with-description - * More description for with-description - * - * Even more description for with-description - * - * @cssPart basic - * @cssPart with-summary Summary for :part(with-summary) - * @cssPart with-summary-dash - Summary for :part(with-summary-dash) - * @cssPart with-summary-colon: Summary for :part(with-summary-colon) - * @cssPart with-description - Summary for :part(with-description) - * Description for :part(with-description) - * More description for :part(with-description) - * - * Even more description for :part(with-description) - * - * @cssProperty --basic - * @cssProperty --with-summary Summary for --with-summary - * @cssProperty --with-summary-dash - Summary for --with-summary-dash - * @cssProperty --with-summary-colon: Summary for --with-summary-colon - * @cssProperty --with-description - Summary for --with-description - * Description for --with-description - * More description for --with-description - * - * Even more description for --with-description - * - * @cssProp --short-basic - * @cssProp --short-with-summary Summary for --short-with-summary - * @cssProp --short-with-summary-dash - Summary for --short-with-summary-dash - * @cssProp --short-with-summary-colon: Summary for --short-with-summary-colon - * @cssProp --short-with-description - Summary for --short-with-description - * Description for --short-with-description - * More description for --short-with-description - * - * Even more description for --short-with-description + * @slot - Description for default slot + * @slot no-description + * @slot with-description Description for with-description + * with wraparound + * @slot with-description-dash - Description for with-description-dash + * @cssPart no-description + * @cssPart with-description Description for :part(with-description) + * with wraparound + * @cssPart with-description-dash - Description for :part(with-description-dash) + * @cssProperty --no-description + * @cssProperty --with-description Description for --with-description + * with wraparound + * @cssProperty --with-description-dash - Description for --with-description-dash + * @cssProp --short-no-description + * @cssProp --short-with-description Description for --short-with-description + * with wraparound + * @cssProp --short-with-description-dash - Description for --short-with-description-dash */ @customElement('element-a') -export class ElementA extends LitElement {} +export class ElementA extends LitElement { + /** + * Class field 1 description + * with wraparound + */ + private field1 = 'default1'; + + /** + * @summary Class field 2 summary + * with wraparound + * + * @description Class field 2 description + * with wraparound + */ + protected field2: number | string; + + /** + * @description Class field 3 description + * with wraparound + * @deprecated + */ + field3?: string; + + /** + * Class field 4 description + * with wraparound + * @summary Class field 4 summary + * with wraparound + * @deprecated Class field 4 deprecated + */ + field4 = new Promise((r) => r()); + + /** + * Method 1 description + * with wraparound + */ + method1() {} + + /** + * @summary Method 2 summary + * with wraparound + * + * @description Method 2 description + * with wraparound + * + * @param a Param a description + * @param b Param b description + * with wraparound + * + * @param c Param c description + * @returns Method 2 return description + * + * @deprecated Method 2 deprecated + */ + method2(a: string, b = false, ...c: number[]) { + return b ? a : c[0].toFixed(); + } +} /** * @description TaggedDescription description. Lorem ipsum dolor sit amet, consectetur @@ -73,15 +108,3 @@ export class TaggedDescription extends LitElement {} * @summary UntaggedDescription summary. */ export class UntaggedDescription extends LitElement {} - -/** - * UntaggedDescSummary summary. - * - * UntaggedDescSummary description. Lorem ipsum dolor sit amet, consectetur - * adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna - * aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris - * nisi ut aliquip ex ea commodo consequat. - * - * @deprecated - */ -export class UntaggedDescSummary extends LitElement {} diff --git a/packages/labs/cli/package.json b/packages/labs/cli/package.json index 5c3aa55c65..881f8370c8 100644 --- a/packages/labs/cli/package.json +++ b/packages/labs/cli/package.json @@ -46,6 +46,7 @@ "../analyzer:build", "../gen-wrapper-react:build", "../gen-wrapper-vue:build", + "../gen-manifest:build", "../gen-utils:build" ] }, diff --git a/packages/labs/cli/src/lib/commands/labs.ts b/packages/labs/cli/src/lib/commands/labs.ts index b7dc97b7d8..ef8f618d34 100644 --- a/packages/labs/cli/src/lib/commands/labs.ts +++ b/packages/labs/cli/src/lib/commands/labs.ts @@ -42,6 +42,12 @@ export const makeLabsCommand = (cli: LitCli): Command => { defaultValue: './gen', description: 'Folder to output generated packages to.', }, + { + name: 'exclude', + multiple: true, + defaultValue: [], + description: 'Glob of source files to exclude from analysis.', + }, ], async run( { @@ -49,16 +55,21 @@ export const makeLabsCommand = (cli: LitCli): Command => { framework: frameworks, manifest, out: outDir, + exclude, }: { package: string[]; framework: string[]; manifest: boolean; out: string; + exclude: string[]; }, console: Console ) { const gen = await import('../generate/generate.js'); - await gen.run({cli, packages, frameworks, manifest, outDir}, console); + await gen.run( + {cli, packages, frameworks, manifest, outDir, exclude}, + console + ); }, }, ], diff --git a/packages/labs/cli/src/lib/generate/generate.ts b/packages/labs/cli/src/lib/generate/generate.ts index 0b2fdef86e..f838210870 100644 --- a/packages/labs/cli/src/lib/generate/generate.ts +++ b/packages/labs/cli/src/lib/generate/generate.ts @@ -54,85 +54,92 @@ export const run = async ( packages, frameworks: frameworkNames, manifest, + exclude, outDir, }: { packages: string[]; frameworks: string[]; manifest: boolean; outDir: string; + exclude?: string[]; cli: LitCli; }, console: Console ) => { - for (const packageRoot of packages) { - // Ensure separators in input paths are normalized and resolved to absolute - const root = path.normalize(path.resolve(packageRoot)) as AbsolutePath; - const out = path.normalize(path.resolve(outDir)) as AbsolutePath; - const analyzer = createPackageAnalyzer(root); - const pkg = analyzer.getPackage(); - if (!pkg.packageJson.name) { - throw new Error( - `Package at '${packageRoot}' did not have a name in package.json. The 'gen' command requires that packages have a name.` - ); - } - const generatorReferences = []; - for (const name of (frameworkNames ?? []) as FrameworkName[]) { - const framework = frameworkCommands[name]; - if (framework == null) { - throw new Error(`No generator exists for framework '${framework}'`); + try { + for (const packageRoot of packages) { + // Ensure separators in input paths are normalized and resolved to absolute + const root = path.normalize(path.resolve(packageRoot)) as AbsolutePath; + const out = path.normalize(path.resolve(outDir)) as AbsolutePath; + const analyzer = createPackageAnalyzer(root, {exclude}); + const pkg = analyzer.getPackage(); + if (!pkg.packageJson.name) { + throw new Error( + `Package at '${packageRoot}' did not have a name in package.json. The 'gen' command requires that packages have a name.` + ); } - generatorReferences.push(framework); - } - if (manifest) { - generatorReferences.push(manifestCommand); - } - // Optimistically try to import all generators in parallel. - // If any aren't installed we need to ask for permission to install it - // below, but in the common happy case this will do all the loading work. - await Promise.all( - generatorReferences.map(async (ref) => { - const specifier = cli.resolveImportForReference(ref); - if (specifier != null) { - await import(specifier); + const generatorReferences = []; + for (const name of (frameworkNames ?? []) as FrameworkName[]) { + const framework = frameworkCommands[name]; + if (framework == null) { + throw new Error(`No generator exists for framework '${framework}'`); + } + generatorReferences.push(framework); + } + if (manifest) { + generatorReferences.push(manifestCommand); + } + // Optimistically try to import all generators in parallel. + // If any aren't installed we need to ask for permission to install it + // below, but in the common happy case this will do all the loading work. + await Promise.all( + generatorReferences.map(async (ref) => { + const specifier = cli.resolveImportForReference(ref); + if (specifier != null) { + await import(specifier); + } + }) + ); + // Now go through one by one and install any as necessary. + // This must be one by one in case we need to ask for permission. + const generators: GenerateCommand[] = []; + for (const reference of generatorReferences) { + const resolved = await cli.resolveCommandAndMaybeInstallNeededDeps( + reference + ); + if (resolved == null) { + throw new Error(`Could not load generator for ${reference.name}`); } - }) - ); - // Now go through one by one and install any as necessary. - // This must be one by one in case we need to ask for permission. - const generators: GenerateCommand[] = []; - for (const reference of generatorReferences) { - const resolved = await cli.resolveCommandAndMaybeInstallNeededDeps( - reference + generators.push(resolved as unknown as GenerateCommand); + } + const options = { + package: pkg, + }; + const results = await Promise.allSettled( + generators.map(async (generator) => { + // TODO(kschaaf): Add try/catches around each of these operations and + // throw more contextural errors + await writeFileTree(out, await generator.generate(options, console)); + }) ); - if (resolved == null) { - throw new Error(`Could not load generator for ${reference.name}`); + // `allSettled` will swallow errors, so we need to filter them out of + // the results and throw a new error up the stack describing all the errors + // that happened + const errors = results + .map((r, i) => + r.status === 'rejected' + ? `Error generating '${generators[i].name}' wrapper for package '${packageRoot}': ` + + (r.reason as Error).stack ?? r.reason + : '' + ) + .filter((e) => e) + .join('\n'); + if (errors.length > 0) { + throw new Error(errors); } - generators.push(resolved as unknown as GenerateCommand); - } - const options = { - package: pkg, - }; - const results = await Promise.allSettled( - generators.map(async (generator) => { - // TODO(kschaaf): Add try/catches around each of these operations and - // throw more contextural errors - await writeFileTree(out, await generator.generate(options, console)); - }) - ); - // `allSettled` will swallow errors, so we need to filter them out of - // the results and throw a new error up the stack describing all the errors - // that happened - const errors = results - .map((r, i) => - r.status === 'rejected' - ? `Error generating '${generators[i].name}' wrapper for package '${packageRoot}': ` + - (r.reason as Error).stack ?? r.reason - : '' - ) - .filter((e) => e) - .join('\n'); - if (errors.length > 0) { - throw new Error(errors); } + } catch (e) { + console.error((e as Error).message ?? e); + return 1; } }; diff --git a/packages/labs/gen-manifest/goldens/test-element-a/custom-elements.json b/packages/labs/gen-manifest/goldens/test-element-a/custom-elements.json index 8d94d86acc..05d67e9e9e 100644 --- a/packages/labs/gen-manifest/goldens/test-element-a/custom-elements.json +++ b/packages/labs/gen-manifest/goldens/test-element-a/custom-elements.json @@ -1,20 +1,10 @@ { "schemaVersion": "1.0.0", "modules": [ - { - "kind": "javascript-module", - "path": "detail-type.js", - "summary": "TODO", - "description": "TODO", - "declarations": [], - "exports": [], - "deprecated": false - }, + {"kind": "javascript-module", "path": "detail-type.js"}, { "kind": "javascript-module", "path": "element-a.js", - "summary": "TODO", - "description": "TODO", "declarations": [ { "kind": "class", @@ -22,33 +12,82 @@ "description": "This is a description of my element. It's pretty great. The description has\ntext that spans multiple lines.", "summary": "My awesome element", "superclass": {"name": "LitElement", "package": "lit"}, - "mixins": [], - "members": [], - "source": {"href": "TODO"}, - "deprecated": false, + "members": [ + { + "kind": "field", + "name": "styles", + "static": true, + "privacy": "public", + "type": { + "text": "CSSResult", + "references": [ + { + "name": "CSSResult", + "package": "@lit/reactive-element", + "module": "css-tag.js", + "start": 0, + "end": 9 + } + ] + }, + "default": "css`\n :host {\n display: block;\n background-color: var(--background-color);\n color: var(-foreground-color);\n }\n `" + }, + { + "kind": "field", + "name": "foo", + "privacy": "public", + "type": {"text": "string | undefined"} + }, + { + "kind": "method", + "name": "render", + "privacy": "public", + "return": { + "type": { + "text": "TemplateResult<1>", + "references": [ + { + "name": "TemplateResult", + "package": "lit-html", + "start": 0, + "end": 14 + } + ] + } + } + } + ], "tagName": "element-a", - "attributes": [], - "events": [{"name": "a-changed", "type": {"text": "TODO"}}], + "customElement": true, + "events": [ + { + "name": "a-changed", + "type": {"text": "Event"}, + "description": "An awesome event to fire" + } + ], "slots": [ - {"name": "default", "summary": "The default slot"}, - {"name": "stuff", "summary": "A slot for stuff"} + {"name": "default", "description": "The default slot"}, + {"name": "stuff", "description": "A slot for stuff"} ], "cssParts": [ - {"name": "header", "summary": "The header"}, - {"name": "footer", "summary": "The footer"} + {"name": "header", "description": "The header"}, + {"name": "footer", "description": "The footer"} ], "cssProperties": [ - {"name": "--foreground-color", "summary": "The foreground color"}, - {"name": "--background-color", "summary": "The background color"} - ], - "demos": [], - "customElement": true + { + "name": "--foreground-color", + "description": "The foreground color" + }, + { + "name": "--background-color", + "description": "The background color" + } + ] }, { "kind": "variable", "name": "localTypeVar", - "summary": "TODO", - "description": "TODO", "type": { "text": "ElementA", "references": [ @@ -65,8 +104,6 @@ { "kind": "variable", "name": "packageTypeVar", - "summary": "TODO", - "description": "TODO", "type": { "text": "Foo", "references": [ @@ -90,8 +127,6 @@ { "kind": "variable", "name": "externalTypeVar", - "summary": "TODO", - "description": "TODO", "type": { "text": "LitElement", "references": [ @@ -102,8 +137,6 @@ { "kind": "variable", "name": "globalTypeVar", - "summary": "TODO", - "description": "TODO", "type": { "text": "HTMLElement", "references": [ @@ -167,35 +200,196 @@ "name": "element-a", "declaration": {"name": "ElementA"} } - ], - "deprecated": false + ] }, { "kind": "javascript-module", "path": "element-events.js", - "summary": "TODO", - "description": "TODO", "declarations": [ { "kind": "class", "name": "EventSubclass", "superclass": {"name": "Event", "package": "global:"}, - "mixins": [], - "members": [], - "source": {"href": "TODO"}, - "deprecated": false + "members": [ + { + "kind": "field", + "name": "aStr", + "privacy": "public", + "type": {"text": "string"} + }, + { + "kind": "field", + "name": "aNumber", + "privacy": "public", + "type": {"text": "number"} + } + ] }, { "kind": "class", "name": "ElementEvents", "description": "My awesome element", "superclass": {"name": "LitElement", "package": "lit"}, - "mixins": [], - "members": [], - "source": {"href": "TODO"}, - "deprecated": false, + "members": [ + { + "kind": "field", + "name": "foo", + "privacy": "public", + "type": {"text": "string | undefined"} + }, + { + "kind": "method", + "name": "render", + "privacy": "public", + "return": { + "type": { + "text": "TemplateResult<1>", + "references": [ + { + "name": "TemplateResult", + "package": "lit", + "start": 0, + "end": 14 + } + ] + } + } + }, + { + "kind": "method", + "name": "fireStringCustomEvent", + "privacy": "public", + "parameters": [ + { + "name": "detail", + "type": {"text": "string"}, + "default": "'string-event'", + "optional": true + }, + { + "name": "fromNode", + "type": {"text": "this"}, + "default": "this", + "optional": true + } + ], + "return": {"type": {"text": "void"}} + }, + { + "kind": "method", + "name": "fireNumberCustomEvent", + "privacy": "public", + "parameters": [ + { + "name": "detail", + "type": {"text": "number"}, + "default": "11", + "optional": true + }, + { + "name": "fromNode", + "type": {"text": "this"}, + "default": "this", + "optional": true + } + ], + "return": {"type": {"text": "void"}} + }, + { + "kind": "method", + "name": "fireMyDetailCustomEvent", + "privacy": "public", + "parameters": [ + { + "name": "detail", + "type": { + "text": "MyDetail", + "references": [ + { + "name": "MyDetail", + "package": "@lit-internal/test-element-a", + "module": "detail-type.js", + "start": 0, + "end": 8 + } + ] + }, + "default": "{a: 'a', b: 5} as MyDetail", + "optional": true + }, + { + "name": "fromNode", + "type": {"text": "this"}, + "default": "this", + "optional": true + } + ], + "return": {"type": {"text": "void"}} + }, + { + "kind": "method", + "name": "fireTemplateResultCustomEvent", + "privacy": "public", + "parameters": [ + { + "name": "detail", + "type": { + "text": "TemplateResult<1>", + "references": [ + { + "name": "TemplateResult", + "package": "lit", + "start": 0, + "end": 14 + } + ] + }, + "default": "html``", + "optional": true + }, + { + "name": "fromNode", + "type": {"text": "this"}, + "default": "this", + "optional": true + } + ], + "return": {"type": {"text": "void"}} + }, + { + "kind": "method", + "name": "fireEventSubclass", + "privacy": "public", + "parameters": [ + {"name": "str", "type": {"text": "string"}}, + {"name": "num", "type": {"text": "number"}}, + { + "name": "fromNode", + "type": {"text": "this"}, + "default": "this", + "optional": true + } + ], + "return": {"type": {"text": "void"}} + }, + { + "kind": "method", + "name": "fireSpecialEvent", + "privacy": "public", + "parameters": [ + {"name": "num", "type": {"text": "number"}}, + { + "name": "fromNode", + "type": {"text": "this"}, + "default": "this", + "optional": true + } + ], + "return": {"type": {"text": "void"}} + } + ], "tagName": "element-events", - "attributes": [], + "customElement": true, "events": [ { "name": "string-custom-event", @@ -209,7 +403,8 @@ "end": 11 } ] - } + }, + "description": "A custom event with a string payload" }, { "name": "number-custom-event", @@ -223,7 +418,8 @@ "end": 11 } ] - } + }, + "description": "A custom event with a number payload" }, { "name": "my-detail-custom-event", @@ -244,7 +440,8 @@ "end": 20 } ] - } + }, + "description": "A custom event with a MyDetail payload." }, { "name": "event-subclass", @@ -259,7 +456,8 @@ "end": 13 } ] - } + }, + "description": "The subclass event with a string and number payload" }, { "name": "special-event", @@ -274,7 +472,8 @@ "end": 12 } ] - } + }, + "description": "The special event with a number payload" }, { "name": "template-result-custom-event", @@ -294,14 +493,10 @@ "end": 26 } ] - } + }, + "description": "The result-custom-event with a TemplateResult payload." } - ], - "slots": [], - "cssParts": [], - "cssProperties": [], - "demos": [], - "customElement": true + ] } ], "exports": [ @@ -338,32 +533,99 @@ "name": "element-events", "declaration": {"name": "ElementEvents"} } - ], - "deprecated": false + ] }, { "kind": "javascript-module", "path": "element-props.js", - "summary": "TODO", - "description": "TODO", "declarations": [ { "kind": "class", "name": "ElementProps", "description": "My awesome element", "superclass": {"name": "LitElement", "package": "lit"}, - "mixins": [], - "members": [], - "source": {"href": "TODO"}, - "deprecated": false, + "members": [ + { + "kind": "field", + "name": "aStr", + "privacy": "public", + "type": {"text": "string"}, + "default": "'aStr'" + }, + { + "kind": "field", + "name": "aNum", + "privacy": "public", + "type": {"text": "number"}, + "default": "-1" + }, + { + "kind": "field", + "name": "aBool", + "privacy": "public", + "type": {"text": "boolean"}, + "default": "false" + }, + { + "kind": "field", + "name": "aStrArray", + "privacy": "public", + "type": {"text": "string[]"}, + "default": "['a', 'b']" + }, + { + "kind": "field", + "name": "aMyType", + "privacy": "public", + "type": { + "text": "MyType", + "references": [ + { + "name": "MyType", + "package": "@lit-internal/test-element-a", + "module": "element-props.js", + "start": 0, + "end": 6 + } + ] + }, + "default": "{\n a: 'a',\n b: -1,\n c: false,\n d: ['a', 'b'],\n e: 'isUnknown',\n strOrNum: 'strOrNum',\n }" + }, + { + "kind": "field", + "name": "aState", + "privacy": "public", + "type": {"text": "string"}, + "default": "'aState'" + }, + { + "kind": "method", + "name": "render", + "privacy": "public", + "return": { + "type": { + "text": "TemplateResult<1>", + "references": [ + { + "name": "TemplateResult", + "package": "lit-html", + "start": 0, + "end": 14 + } + ] + } + } + } + ], "tagName": "element-props", - "attributes": [], - "events": [{"name": "a-changed", "type": {"text": "TODO"}}], - "slots": [], - "cssParts": [], - "cssProperties": [], - "demos": [], - "customElement": true + "customElement": true, + "events": [ + { + "name": "a-changed", + "type": {"text": "Event"}, + "description": "An awesome event to fire" + } + ] } ], "exports": [ @@ -377,31 +639,45 @@ "name": "element-props", "declaration": {"name": "ElementProps"} } - ], - "deprecated": false + ] }, { "kind": "javascript-module", "path": "element-slots.js", - "summary": "TODO", - "description": "TODO", "declarations": [ { "kind": "class", "name": "ElementSlots", "description": "My awesome element", "superclass": {"name": "LitElement", "package": "lit"}, - "mixins": [], - "members": [], - "source": {"href": "TODO"}, - "deprecated": false, + "members": [ + { + "kind": "field", + "name": "mainDefault", + "privacy": "public", + "type": {"text": "string"}, + "default": "'mainDefault'" + }, + { + "kind": "method", + "name": "render", + "privacy": "public", + "return": { + "type": { + "text": "TemplateResult<1>", + "references": [ + { + "name": "TemplateResult", + "package": "lit-html", + "start": 0, + "end": 14 + } + ] + } + } + } + ], "tagName": "element-slots", - "attributes": [], - "events": [], - "slots": [], - "cssParts": [], - "cssProperties": [], - "demos": [], "customElement": true } ], @@ -416,52 +692,70 @@ "name": "element-slots", "declaration": {"name": "ElementSlots"} } - ], - "deprecated": false + ] }, { "kind": "javascript-module", "path": "package-stuff.js", - "summary": "TODO", - "description": "TODO", "declarations": [ { "kind": "class", "name": "Bar", - "mixins": [], - "members": [], - "source": {"href": "TODO"}, - "deprecated": false + "members": [ + { + "kind": "field", + "name": "bar", + "privacy": "public", + "type": {"text": "boolean"}, + "default": "true" + } + ] }, { "kind": "class", "name": "Foo", - "mixins": [], - "members": [], - "source": {"href": "TODO"}, - "deprecated": false + "members": [ + { + "kind": "field", + "name": "bar", + "privacy": "public", + "type": { + "text": "T | undefined", + "references": [ + { + "name": "T", + "package": "@lit-internal/test-element-a", + "module": "package-stuff.js", + "start": 0, + "end": 1 + } + ] + } + } + ] } ], "exports": [ {"kind": "js", "name": "Bar", "declaration": {"name": "Bar"}}, {"kind": "js", "name": "Foo", "declaration": {"name": "Foo"}} - ], - "deprecated": false + ] }, { "kind": "javascript-module", "path": "special-event.js", - "summary": "TODO", - "description": "TODO", "declarations": [ { "kind": "class", "name": "SpecialEvent", "superclass": {"name": "Event", "package": "global:"}, - "mixins": [], - "members": [], - "source": {"href": "TODO"}, - "deprecated": false + "members": [ + { + "kind": "field", + "name": "aNumber", + "privacy": "public", + "type": {"text": "number"} + } + ] } ], "exports": [ @@ -470,8 +764,7 @@ "name": "SpecialEvent", "declaration": {"name": "SpecialEvent"} } - ], - "deprecated": false + ] } ] } diff --git a/packages/labs/gen-manifest/src/index.ts b/packages/labs/gen-manifest/src/index.ts index dd7e61ccb4..1598124c0f 100644 --- a/packages/labs/gen-manifest/src/index.ts +++ b/packages/labs/gen-manifest/src/index.ts @@ -15,16 +15,45 @@ import { Type, VariableDeclaration, LitElementExport, + ClassField, + ClassMethod, + Parameter, + Return, + DeprecatableDescribed, } from '@lit-labs/analyzer'; import {FileTree} from '@lit-labs/gen-utils/lib/file-utils.js'; import type * as cem from 'custom-elements-manifest/schema'; -const ifDefined = (model: O, name: K) => { - const obj: Partial> = {}; - if (model[name] !== undefined) { - obj[name] = model[name]; +/** + * For optional entries in the manifest, if the value has no meaningful value + * (i.e. it's an empty string or array or `false`), return `undefined` so that + * we don't serialize a key with a meaningless value to JSON, to cut down size + * of the manifest (note that `JSON.serialize` will not emit keys with + * `undefined` values) + */ +const ifNotEmpty = (v: T): T | undefined => { + if ( + (v as unknown) === false || + ((typeof v === 'string' || Array.isArray(v)) && v.length === 0) + ) { + return undefined; + } + return v; +}; + +/** + * Transform the given value only if it had a meaningful value, otherwise + * return `undefined` so that it is not serialized to JSON. + */ +const transformIfNotEmpty = ( + value: T, + transformer: (v: NonNullable) => R +): R | undefined => { + const v = ifNotEmpty(value); + if (v !== undefined) { + return ifNotEmpty(transformer(v as NonNullable)); } - return obj; + return undefined; }; /** @@ -62,16 +91,33 @@ const convertModule = (module: Module): cem.Module => { return { kind: 'javascript-module', path: module.jsPath, - summary: 'TODO', // TODO - description: 'TODO', // TODO - declarations: [...module.declarations.map(convertDeclaration)], - exports: [ + description: ifNotEmpty(module.description), + summary: ifNotEmpty(module.summary), + deprecated: ifNotEmpty(module.deprecated), + declarations: transformIfNotEmpty(module.declarations, (d) => + d.map(convertDeclaration) + ), + exports: ifNotEmpty([ ...module.exportNames.map((name) => convertJavascriptExport(name, module.getExportReference(name)) ), ...module.getCustomElementExports().map(convertCustomElementExport), - ], - deprecated: false, // TODO + ]), + }; +}; + +const convertCommonInfo = (info: DeprecatableDescribed) => { + return { + description: ifNotEmpty(info.description), + summary: ifNotEmpty(info.summary), + deprecated: ifNotEmpty(info.deprecated), + }; +}; + +const convertCommonDeclarationInfo = (declaration: Declaration) => { + return { + name: declaration.name!, // TODO(kschaaf) name isn't optional in CEM + ...convertCommonInfo(declaration), }; }; @@ -121,37 +167,103 @@ const convertLitElementDeclaration = ( return { ...convertClassDeclaration(declaration), tagName: declaration.tagname, - attributes: [ - // TODO - ], - events: Array.from(declaration.events.values()).map(convertEvent), - slots: Array.from(declaration.slots.values()), - cssParts: Array.from(declaration.cssParts.values()), - cssProperties: Array.from(declaration.cssProperties.values()), - demos: [ - // TODO - ], customElement: true, + // attributes: [], // TODO + events: transformIfNotEmpty(declaration.events, (v) => + Array.from(v.values()).map(convertEvent) + ), + slots: transformIfNotEmpty(declaration.slots, (v) => + Array.from(v.values()) + ), + cssParts: transformIfNotEmpty(declaration.cssParts, (v) => + Array.from(v.values()) + ), + cssProperties: transformIfNotEmpty(declaration.cssProperties, (v) => + Array.from(v.values()) + ), + // demos: [], // TODO }; }; const convertClassDeclaration = ( declaration: ClassDeclaration ): cem.ClassDeclaration => { - const {superClass} = declaration.heritage; return { kind: 'class', - name: declaration.name!, // TODO(kschaaf) name isn't optional in CEM - ...ifDefined(declaration, 'description'), - ...ifDefined(declaration, 'summary'), - superclass: superClass ? convertReference(superClass) : undefined, - mixins: [], // TODO - members: [ - // TODO: ClassField - // TODO: ClassMethod - ], - source: {href: 'TODO'}, // TODO - deprecated: false, // TODO + ...convertCommonDeclarationInfo(declaration), + superclass: transformIfNotEmpty( + declaration.heritage.superClass, + convertReference + ), + // mixins: [], // TODO + members: ifNotEmpty([ + ...Array.from(declaration.fields).map(convertClassField), + ...Array.from(declaration.methods).map(convertClassMethod), + ]), + // source: {href: 'TODO'}, // TODO + }; +}; + +const convertCommonMemberInfo = (member: ClassField | ClassMethod) => { + return { + static: ifNotEmpty(member.static), + privacy: ifNotEmpty(member.privacy), + inheritedFrom: transformIfNotEmpty(member.inheritedFrom, convertReference), + source: ifNotEmpty(member.source), + }; +}; + +const convertCommonFunctionLikeInfo = (functionLike: ClassMethod) => { + return { + parameters: transformIfNotEmpty(functionLike.parameters, (p) => + p.map(convertParameter) + ), + return: transformIfNotEmpty(functionLike.return, convertReturn), + }; +}; + +const convertReturn = (ret: Return) => { + return { + type: transformIfNotEmpty(ret.type, convertType), + summary: ifNotEmpty(ret.summary), + description: ifNotEmpty(ret.description), + }; +}; + +const convertCommonPropertyLikeInfo = ( + propertyLike: Parameter | ClassField +) => { + return { + type: transformIfNotEmpty(propertyLike.type, convertType), + default: ifNotEmpty(propertyLike.default), + }; +}; + +const convertParameter = (param: Parameter): cem.Parameter => { + return { + name: param.name, + ...convertCommonInfo(param), + ...convertCommonPropertyLikeInfo(param), + optional: ifNotEmpty(param.optional), + rest: ifNotEmpty(param.rest), + }; +}; + +const convertClassField = (field: ClassField): cem.ClassField => { + return { + kind: 'field', + ...convertCommonDeclarationInfo(field), + ...convertCommonMemberInfo(field), + ...convertCommonPropertyLikeInfo(field), + }; +}; + +const convertClassMethod = (method: ClassMethod): cem.ClassMethod => { + return { + kind: 'method', + ...convertCommonDeclarationInfo(method), + ...convertCommonMemberInfo(method), + ...convertCommonFunctionLikeInfo(method), }; }; @@ -160,28 +272,32 @@ const convertVariableDeclaration = ( ): cem.VariableDeclaration => { return { kind: 'variable', - name: declaration.name, - summary: 'TODO', // TODO - description: 'TODO', // TODO - type: declaration.type ? convertType(declaration.type) : {text: 'TODO'}, // TODO(kschaaf) type isn't optional in CEM + ...convertCommonDeclarationInfo(declaration), + type: transformIfNotEmpty(declaration.type, convertType) ?? { + text: 'unknown', + }, // TODO(kschaaf) type isn't optional in CEM }; }; const convertEvent = (event: Event): cem.Event => { return { name: event.name, - type: event.type ? convertType(event.type) : {text: 'TODO'}, // TODO(kschaaf) type isn't optional in CEM + type: transformIfNotEmpty(event.type, convertType) ?? {text: 'Event'}, // TODO(kschaaf) type isn't optional in CEM + description: ifNotEmpty(event.description), + summary: ifNotEmpty(event.summary), }; }; const convertType = (type: Type): cem.Type => { return { text: type.text, - references: convertTypeReference(type.text, type.references), + references: transformIfNotEmpty(type.references, (r) => + convertTypeReferences(type.text, r) + ), }; }; -const convertTypeReference = ( +const convertTypeReferences = ( text: string, references: Reference[] ): cem.TypeReference[] => { diff --git a/packages/labs/test-projects/test-element-a/src/element-a.ts b/packages/labs/test-projects/test-element-a/src/element-a.ts index 609eddc7b5..22a35dc37c 100644 --- a/packages/labs/test-projects/test-element-a/src/element-a.ts +++ b/packages/labs/test-projects/test-element-a/src/element-a.ts @@ -9,15 +9,14 @@ import {customElement, property} from 'lit/decorators.js'; import {Foo, Bar as Baz} from './package-stuff.js'; /** - * My awesome element - * * This is a description of my element. It's pretty great. The description has * text that spans multiple lines. * + * @summary My awesome element * @fires a-changed - An awesome event to fire * @slot default - The default slot * @slot stuff - A slot for stuff - * @cssProperty --foreground-color: The foreground color + * @cssProperty --foreground-color - The foreground color * @cssProp --background-color The background color * @cssPart header The header * @cssPart footer - The footer