From e0fc2bd51c650ab3fd88d366ae37848f768a8926 Mon Sep 17 00:00:00 2001 From: Paul Gschwendtner Date: Wed, 24 Jan 2018 01:08:11 +0100 Subject: [PATCH] chore(dgeni): better extraction of directive metadata (#9387) * chore(dgeni): better extraction of directive metadata * No longer extracts the directive/component metadata using Regular Expressions. * Fixes that inherited properties from interfaces or super-classes are not showing up as inputs/outputs if they are specified in the component/directive metadata. * Now handles multi-line selectors (this was not possible using the Regular Expression) * Fixes that merged inherited properties have a reference to the original document (this causes unexpected behavior; if properties are updated). References #9299 --- tools/dgeni/common/decorators.ts | 35 ++------- tools/dgeni/common/dgeni-definitions.ts | 1 + tools/dgeni/common/directive-metadata.ts | 73 +++++++++++++++++++ tools/dgeni/common/property-bindings.ts | 53 ++++++++++++++ tools/dgeni/common/sort-members.ts | 9 +-- tools/dgeni/processors/categorizer.ts | 47 ++++++------ .../processors/merge-inherited-properties.ts | 9 ++- 7 files changed, 166 insertions(+), 61 deletions(-) create mode 100644 tools/dgeni/common/directive-metadata.ts create mode 100644 tools/dgeni/common/property-bindings.ts diff --git a/tools/dgeni/common/decorators.ts b/tools/dgeni/common/decorators.ts index 74779ce002c..81aa7c24de5 100644 --- a/tools/dgeni/common/decorators.ts +++ b/tools/dgeni/common/decorators.ts @@ -6,6 +6,7 @@ import {ClassExportDoc} from 'dgeni-packages/typescript/api-doc-types/ClassExportDoc'; import {PropertyMemberDoc} from 'dgeni-packages/typescript/api-doc-types/PropertyMemberDoc'; import {MemberDoc} from 'dgeni-packages/typescript/api-doc-types/MemberDoc'; +import {CategorizedClassDoc} from './dgeni-definitions'; const SELECTOR_BLACKLIST = new Set([ '[portal]', @@ -49,28 +50,16 @@ export function isNgModule(doc: ClassExportDoc) { return hasClassDecorator(doc, 'NgModule'); } -export function isDirectiveOutput(doc: PropertyMemberDoc) { - return hasMemberDecorator(doc, 'Output'); -} - -export function isDirectiveInput(doc: PropertyMemberDoc) { - return hasMemberDecorator(doc, 'Input'); -} - export function isDeprecatedDoc(doc: any) { return (doc.tags && doc.tags.tags || []).some((tag: any) => tag.tagName === 'deprecated'); } -export function getDirectiveInputAlias(doc: PropertyMemberDoc) { - return isDirectiveInput(doc) ? doc.decorators!.find(d => d.name == 'Input')!.arguments![0] : ''; -} +export function getDirectiveSelectors(classDoc: CategorizedClassDoc) { + if (!classDoc.directiveMetadata) { + return; + } -export function getDirectiveOutputAlias(doc: PropertyMemberDoc) { - return isDirectiveOutput(doc) ? doc.decorators!.find(d => d.name == 'Output')!.arguments![0] : ''; -} - -export function getDirectiveSelectors(classDoc: ClassExportDoc) { - const directiveSelectors = getMetadataProperty(classDoc, 'selector'); + const directiveSelectors: string = classDoc.directiveMetadata.get('selector'); if (directiveSelectors) { // Filter blacklisted selectors and remove line-breaks in resolved selectors. @@ -79,18 +68,6 @@ export function getDirectiveSelectors(classDoc: ClassExportDoc) { } } -export function getMetadataProperty(doc: ClassExportDoc, property: string) { - const metadata = doc.decorators! - .find(d => d.name === 'Component' || d.name === 'Directive')!.arguments![0]; - - // Use a Regex to determine the given metadata property. This is necessary, because we can't - // parse the JSON due to environment variables inside of the JSON (e.g module.id) - const matches = new RegExp(`${property}s*:\\s*(?:"|'|\`)((?:.|\\n|\\r)+?)(?:"|'|\`)`) - .exec(metadata); - - return matches && matches[1].trim(); -} - export function hasMemberDecorator(doc: MemberDoc, decoratorName: string) { return doc.docType == 'member' && hasDecorator(doc, decoratorName); } diff --git a/tools/dgeni/common/dgeni-definitions.ts b/tools/dgeni/common/dgeni-definitions.ts index 473e7a63f01..03170ad6c97 100644 --- a/tools/dgeni/common/dgeni-definitions.ts +++ b/tools/dgeni/common/dgeni-definitions.ts @@ -17,6 +17,7 @@ export interface CategorizedClassDoc extends ClassExportDoc, CategorizedClassLik isNgModule: boolean; directiveExportAs?: string | null; directiveSelectors?: string[]; + directiveMetadata: Map | null; extendedDoc: ClassLikeExportDoc | null; } diff --git a/tools/dgeni/common/directive-metadata.ts b/tools/dgeni/common/directive-metadata.ts new file mode 100644 index 00000000000..f72d59bdfcb --- /dev/null +++ b/tools/dgeni/common/directive-metadata.ts @@ -0,0 +1,73 @@ +import {CategorizedClassDoc} from './dgeni-definitions'; +import { + ArrayLiteralExpression, + CallExpression, + ObjectLiteralExpression, + PropertyAssignment, + StringLiteral, SyntaxKind +} from 'typescript'; + +/** + * Determines the component or directive metadata from the specified Dgeni class doc. The resolved + * directive metadata will be stored in a Map. + * + * Currently only string literal assignments and array literal assignments are supported. Other + * value types are not necessary because they are not needed for any user-facing documentation. + * + * ```ts + * @Component({ + * inputs: ["red", "blue"], + * exportAs: "test" + * }) + * export class MyComponent {} + * ``` + */ +export function getDirectiveMetadata(classDoc: CategorizedClassDoc): Map | null { + const declaration = classDoc.symbol.valueDeclaration; + + if (!declaration || !declaration.decorators) { + return null; + } + + const directiveDecorator = declaration.decorators + .filter(decorator => decorator.expression) + .filter(decorator => decorator.expression.kind === SyntaxKind.CallExpression) + .find(decorator => (decorator.expression as any).expression.getText() === 'Component' || + (decorator.expression as any).expression.getText() === 'Directive'); + + if (!directiveDecorator) { + return null; + } + + // Since the actual decorator expression is by default a LeftHandSideExpression, and TypeScript + // doesn't allow a casting it to a CallExpression, we have to cast it to "any" before. + const expression = (directiveDecorator.expression as any) as CallExpression; + + // The argument length of the CallExpression needs to be exactly one, because it's the single + // JSON object in the @Component/@Directive decorator. + if (expression.arguments.length !== 1) { + return null; + } + + const objectExpression = expression.arguments[0] as ObjectLiteralExpression; + const resultMetadata = new Map(); + + objectExpression.properties.forEach((prop: PropertyAssignment) => { + + // Support ArrayLiteralExpression assignments in the directive metadata. + if (prop.initializer.kind === SyntaxKind.ArrayLiteralExpression) { + const arrayData = (prop.initializer as ArrayLiteralExpression).elements + .map((literal: StringLiteral) => literal.text); + + resultMetadata.set(prop.name.getText(), arrayData); + } + + // Support normal StringLiteral and NoSubstitutionTemplateLiteral assignments + if (prop.initializer.kind === SyntaxKind.StringLiteral || + prop.initializer.kind === SyntaxKind.NoSubstitutionTemplateLiteral) { + resultMetadata.set(prop.name.getText(), (prop.initializer as StringLiteral).text); + } + }); + + return resultMetadata; +} diff --git a/tools/dgeni/common/property-bindings.ts b/tools/dgeni/common/property-bindings.ts new file mode 100644 index 00000000000..e985e1d26c3 --- /dev/null +++ b/tools/dgeni/common/property-bindings.ts @@ -0,0 +1,53 @@ +import {PropertyMemberDoc} from 'dgeni-packages/typescript/api-doc-types/PropertyMemberDoc'; +import {hasMemberDecorator} from './decorators'; + +/** Interface that describes an Angular property binding. Can be either an input or output. */ +export interface PropertyBinding { + name: string; + alias?: string; +} + +/** + * Detects whether the specified property member is an input. If the property is an input, the + * alias and input name will be returned. + */ +export function getInputBindingData(doc: PropertyMemberDoc, metadata: Map) + : PropertyBinding | undefined { + return getBindingPropertyData(doc, metadata, 'inputs', 'Input'); +} + +/** + * Detects whether the specified property member is an output. If the property is an output, the + * alias and output name will be returned. + */ +export function getOutputBindingData(doc: PropertyMemberDoc, metadata: Map) + : PropertyBinding | undefined { + return getBindingPropertyData(doc, metadata, 'outputs', 'Output'); +} + +/** + * Method that detects the specified type of property binding (either "output" or "input") from + * the directive metadata or from the associated decorator on the property. + */ +function getBindingPropertyData(doc: PropertyMemberDoc, metadata: Map, + propertyName: string, decoratorName: string) { + + if (metadata) { + const metadataValues: string[] = metadata.get(propertyName) || []; + const foundValue = metadataValues.find(value => value.split(':')[0] === doc.name); + + if (foundValue) { + return { + name: doc.name, + alias: foundValue.split(':')[1] + }; + } + } + + if (hasMemberDecorator(doc, decoratorName)) { + return { + name: doc.name, + alias: doc.decorators!.find(d => d.name == decoratorName)!.arguments![0] + }; + } +} diff --git a/tools/dgeni/common/sort-members.ts b/tools/dgeni/common/sort-members.ts index 0a2798c04cf..dffd27a754f 100644 --- a/tools/dgeni/common/sort-members.ts +++ b/tools/dgeni/common/sort-members.ts @@ -1,4 +1,3 @@ -import {isDirectiveInput, isDirectiveOutput} from './decorators'; import {CategorizedMethodMemberDoc, CategorizedPropertyMemberDoc} from './dgeni-definitions'; /** Combined type for a categorized method member document. */ @@ -16,13 +15,13 @@ export function sortCategorizedMembers(docA: CategorizedMemberDoc, docB: Categor } // Sort in the order of: Inputs, Outputs, neither - if ((isDirectiveInput(docA) && !isDirectiveInput(docB)) || - (isDirectiveOutput(docA) && !isDirectiveInput(docB) && !isDirectiveOutput(docB))) { + if ((docA.isDirectiveInput && !docB.isDirectiveInput) || + (docA.isDirectiveOutput && !docB.isDirectiveInput && !docB.isDirectiveOutput)) { return -1; } - if ((isDirectiveInput(docB) && !isDirectiveInput(docA)) || - (isDirectiveOutput(docB) && !isDirectiveInput(docA) && !isDirectiveOutput(docA))) { + if ((docB.isDirectiveInput && !docA.isDirectiveInput) || + (docB.isDirectiveOutput && !docA.isDirectiveInput && !docA.isDirectiveOutput)) { return 1; } diff --git a/tools/dgeni/processors/categorizer.ts b/tools/dgeni/processors/categorizer.ts index 129d7075ddb..6df412f19f2 100644 --- a/tools/dgeni/processors/categorizer.ts +++ b/tools/dgeni/processors/categorizer.ts @@ -1,26 +1,16 @@ import {DocCollection, Processor} from 'dgeni'; import {MethodMemberDoc} from 'dgeni-packages/typescript/api-doc-types/MethodMemberDoc'; +import {getDirectiveMetadata} from '../common/directive-metadata'; import { - decorateDeprecatedDoc, - getDirectiveInputAlias, - getDirectiveOutputAlias, - getDirectiveSelectors, - getMetadataProperty, - isDirective, - isDirectiveInput, - isDirectiveOutput, - isMethod, - isNgModule, - isProperty, + decorateDeprecatedDoc, getDirectiveSelectors, isDirective, isMethod, isNgModule, isProperty, isService } from '../common/decorators'; import { - CategorizedClassDoc, - CategorizedClassLikeDoc, - CategorizedMethodMemberDoc, + CategorizedClassDoc, CategorizedClassLikeDoc, CategorizedMethodMemberDoc, CategorizedPropertyMemberDoc } from '../common/dgeni-definitions'; import {normalizeMethodParameters} from '../common/normalize-method-parameters'; +import {getInputBindingData, getOutputBindingData} from '../common/property-bindings'; import {sortCategorizedMembers} from '../common/sort-members'; @@ -56,6 +46,11 @@ export class Categorizer implements Processor { .filter(isProperty) .filter(filterDuplicateMembers) as CategorizedPropertyMemberDoc[]; + // Special decorations for real class documents that don't apply for interfaces. + if (classLikeDoc.docType === 'class') { + this.decorateClassDoc(classLikeDoc as CategorizedClassDoc); + } + // Call decorate hooks that can modify the method and property docs. classLikeDoc.methods.forEach(doc => this.decorateMethodDoc(doc)); classLikeDoc.properties.forEach(doc => this.decoratePropertyDoc(doc)); @@ -65,11 +60,6 @@ export class Categorizer implements Processor { // Sort members classLikeDoc.methods.sort(sortCategorizedMembers); classLikeDoc.properties.sort(sortCategorizedMembers); - - // Special decorations for real class documents that don't apply for interfaces. - if (classLikeDoc.docType === 'class') { - this.decorateClassDoc(classLikeDoc as CategorizedClassDoc); - } } /** @@ -82,11 +72,12 @@ export class Categorizer implements Processor { // clauses for the Dgeni document. To make the template syntax simpler and more readable, // store the extended class in a variable. classDoc.extendedDoc = classDoc.extendsClauses[0] ? classDoc.extendsClauses[0].doc! : null; + classDoc.directiveMetadata = getDirectiveMetadata(classDoc); // Categorize the current visited classDoc into its Angular type. - if (isDirective(classDoc)) { + if (isDirective(classDoc) && classDoc.directiveMetadata) { classDoc.isDirective = true; - classDoc.directiveExportAs = getMetadataProperty(classDoc, 'exportAs'); + classDoc.directiveExportAs = classDoc.directiveMetadata.get('exportAs'); classDoc.directiveSelectors = getDirectiveSelectors(classDoc); } else if (isService(classDoc)) { classDoc.isService = true; @@ -114,13 +105,17 @@ export class Categorizer implements Processor { private decoratePropertyDoc(propertyDoc: CategorizedPropertyMemberDoc) { decorateDeprecatedDoc(propertyDoc); - // TODO(devversion): detect inputs based on the `inputs` property in the component metadata. + const metadata = propertyDoc.containerDoc.docType === 'class' ? + (propertyDoc.containerDoc as CategorizedClassDoc).directiveMetadata : null; - propertyDoc.isDirectiveInput = isDirectiveInput(propertyDoc); - propertyDoc.directiveInputAlias = getDirectiveInputAlias(propertyDoc); + const inputMetadata = metadata ? getInputBindingData(propertyDoc, metadata) : null; + const outputMetadata = metadata ? getOutputBindingData(propertyDoc, metadata) : null; - propertyDoc.isDirectiveOutput = isDirectiveOutput(propertyDoc); - propertyDoc.directiveOutputAlias = getDirectiveOutputAlias(propertyDoc); + propertyDoc.isDirectiveInput = !!inputMetadata; + propertyDoc.directiveInputAlias = (inputMetadata && inputMetadata.alias) || ''; + + propertyDoc.isDirectiveOutput = !!outputMetadata; + propertyDoc.directiveOutputAlias = (outputMetadata && outputMetadata.alias) || ''; } } diff --git a/tools/dgeni/processors/merge-inherited-properties.ts b/tools/dgeni/processors/merge-inherited-properties.ts index 6415ccade61..5f9f1272188 100644 --- a/tools/dgeni/processors/merge-inherited-properties.ts +++ b/tools/dgeni/processors/merge-inherited-properties.ts @@ -28,7 +28,14 @@ export class MergeInheritedProperties implements Processor { private addMemberDocIfNotPresent(destination: ClassExportDoc, memberDoc: MemberDoc) { if (!destination.members.find(member => member.name === memberDoc.name)) { - destination.members.push(memberDoc); + // To be able to differentiate between member docs from the heritage clause and the + // member doc for the destination class, we clone the member doc. It's important to keep + // the prototype and reference because later, Dgeni identifies members and properties + // by using an instance comparison. + const newMemberDoc = Object.assign(Object.create(memberDoc), memberDoc); + newMemberDoc.containerDoc = destination; + + destination.members.push(newMemberDoc); } } }