@o3r/components
Version:
This module contains component-related features (Component replacement, CMS compatibility, helpers, pipes, debugging developer tools...) It comes with an integrated ng builder to help you generate components compatible with Otter features (CMS integration
494 lines • 27.9 kB
JavaScript
Object.defineProperty(exports, "__esModule", { value: true });
exports.ComponentConfigExtractor = void 0;
const tslib_1 = require("tslib");
const node_fs_1 = require("node:fs");
const extractors_1 = require("@o3r/extractors");
const schematics_1 = require("@o3r/schematics");
const ts = tslib_1.__importStar(require("typescript"));
/** Configuration file extractor */
class ComponentConfigExtractor {
/**
* Configuration file extractor constructor
* @param libraryName
* @param strictMode
* @param source Typescript SourceFile node of the file
* @param logger Logger
* @param filePath Path to the file to extract the configuration from
* @param checker Typescript TypeChecker of the program
* @param libraries
*/
constructor(libraryName, strictMode, source, logger, filePath, checker, libraries = []) {
this.libraryName = libraryName;
this.strictMode = strictMode;
this.source = source;
this.logger = logger;
this.filePath = filePath;
this.checker = checker;
this.libraries = libraries;
/** List of all the configuration patterns that can be used inside a Page/Block or Component */
this.COMPONENT_CONFIGURATION_INTERFACES = [/^Configuration\s*<?/, new RegExp('AppBuildConfiguration'), new RegExp('AppRuntimeConfiguration')];
/** List of all the configuration patterns */
this.CONFIGURATION_INTERFACES = [...this.COMPONENT_CONFIGURATION_INTERFACES, /^NestedConfiguration$/];
/** String to display in case of unknown type */
this.DEFAULT_UNKNOWN_TYPE = 'unknown';
this.configDocParser = new extractors_1.ConfigDocParser();
}
/**
* Handle error cases depending on the mode: throwing errors or logging warnings
* @param message the warning message to be logged
* @param errorMessage the error message to be thrown. If not provided, the warning one will be used
*/
handleErrorCases(message, errorMessage) {
if (this.strictMode) {
throw new schematics_1.O3rCliError(errorMessage || message);
}
else {
this.logger.warn(`${message.replace(/([^.])$/, '$1.')} Will throw in strict mode.`);
}
}
/**
* Verifies if an UnionType has strings elements.
* @param node Typescript node to be checked
*/
hasStringElements(node) {
return node.types.some((type) => ts.isLiteralTypeNode(type) && ts.isStringLiteral(type.literal));
}
/**
* Returns the name of the symbol
* @param symbol
*/
getSymbolName(symbol) {
return symbol.escapedName.toString();
}
/**
* Get the type from a property
* If the type refers to a NestedConfiguration type, then it will be replaced with element
* and a reference to the object will be returned
* @param node Typescript node to extract the data from
* @param configurationWrapper the configuration wrapper containing nestedConfig and union type strings
* @param source
*/
getTypeFromNode(node, configurationWrapper, source = this.source) {
const nestedConfiguration = configurationWrapper?.nestedConfiguration;
const enumTypesAlias = configurationWrapper?.unionTypeStringLiteral;
if (!node) {
return { type: this.DEFAULT_UNKNOWN_TYPE };
}
if (ts.isParenthesizedTypeNode(node)) {
return this.getTypeFromNode(node.type, configurationWrapper);
}
else if (ts.isArrayTypeNode(node)) {
const typeNode = node.elementType;
if (ts.isTypeReferenceNode(typeNode) && ts.isIdentifier(typeNode.typeName)) {
const importFromLibraries = source.statements.find((statement) => ts.isImportDeclaration(statement)
&& ts.isStringLiteral(statement.moduleSpecifier)
&& this.libraries.includes(statement.moduleSpecifier.text)
&& !!statement.importClause?.namedBindings
&& ts.isNamedImports(statement.importClause.namedBindings)
&& !!statement.importClause.namedBindings.elements.some((nameBinding) => ts.isImportSpecifier(nameBinding)
&& ts.isIdentifier(nameBinding.name)
&& nameBinding.name.escapedText.toString() === typeNode.typeName.getText()));
if (importFromLibraries) {
return {
type: 'element[]',
ref: {
library: importFromLibraries.moduleSpecifier.text,
name: typeNode.typeName.getText()
}
};
}
else if (configurationWrapper) {
const type = this.checker.getTypeFromTypeNode(typeNode);
const baseTypes = type.getBaseTypes();
const symbolName = this.getSymbolName(type.symbol || type.aliasSymbol);
const alreadyExtracted = configurationWrapper.nestedConfiguration.some((c) => c.name === symbolName);
const extendsNested = !!baseTypes?.some((baseType) => this.getSymbolName(baseType.symbol).match(/^NestedConfiguration$/));
if (extendsNested && !alreadyExtracted) {
const nestedFilePath = type.symbol.parent.declarations[0].fileName;
const nestedSourceFile = ts.createSourceFile(nestedFilePath, (0, node_fs_1.readFileSync)(nestedFilePath).toString(), ts.ScriptTarget.ES2015, true);
const nestedConfig = this.collectNestedConfiguration(nestedSourceFile);
configurationWrapper.nestedConfiguration = [...(new Set(configurationWrapper.nestedConfiguration.concat(...nestedConfig.nestedConfiguration)))];
configurationWrapper.unionTypeStringLiteral = [...(new Set(configurationWrapper.unionTypeStringLiteral.concat(...nestedConfig.unionTypeStringLiteral)))];
}
}
}
// CMS Team expects element[] type for nested configuration and a reference to the configuration
const childType = this.getTypeFromNode(node.getChildren(source)[0], configurationWrapper);
if ([this.DEFAULT_UNKNOWN_TYPE, 'string', 'number', 'boolean', 'enum'].includes(childType.type)) {
return { type: childType.type + '[]', choices: childType.type === 'enum' ? childType.choices : undefined };
}
return { type: 'element[]', ref: {
library: this.libraryName,
name: childType.type
} };
}
else if (ts.isTypeReferenceNode(node)) {
const name = node.getChildren(source)[0].getText(source);
if (nestedConfiguration && nestedConfiguration.some((nestedConfig) => nestedConfig.name === name)) {
return { type: name };
}
const enumTypeAlias = enumTypesAlias?.find((enumType) => enumType.name === name);
if (enumTypeAlias) {
return { type: 'enum', choices: enumTypeAlias.choices };
}
const nodeType = this.checker.getTypeAtLocation(node);
if (nodeType.isUnion()) {
return {
type: 'enum',
choices: nodeType.types.map((type) => (type.isLiteral() && type.isStringLiteral() && type.value) || '')
};
}
return { type: this.DEFAULT_UNKNOWN_TYPE };
}
if (ts.isUnionTypeNode(node) && this.hasStringElements(node)) {
return {
type: 'enum',
choices: this.extractOptionsForEnum(node)
};
}
// Handle the native types
switch (node.kind) {
case ts.SyntaxKind.StringKeyword: {
return { type: 'string' };
}
case ts.SyntaxKind.BooleanKeyword: {
return { type: 'boolean' };
}
case ts.SyntaxKind.NumberKeyword: {
return { type: 'number' };
}
default: {
return { type: this.DEFAULT_UNKNOWN_TYPE };
}
}
}
/**
* Extract the property data from an interface property signature
* @param propertyNode Node to extract the data from
* @param configurationWrapper the configuration wrapper containing nestedConfig and union type strings
* @param source
*/
extractPropertySignatureData(propertyNode, configurationWrapper, source = this.source) {
const configDocInfo = this.configDocParser.parseConfigDocFromNode(source, propertyNode);
const name = propertyNode.name.getText() || '';
const res = {
description: configDocInfo?.description || '',
category: configDocInfo?.category,
restrictionKeys: configDocInfo?.restrictionKeys,
label: configDocInfo?.label || name.replace(/([A-Z])/g, ' $1'),
name,
type: 'unknown',
widget: configDocInfo?.widget,
// Check to not add `"required": false` on all properties by default
required: configDocInfo?.required ? true : undefined
};
if (propertyNode.questionToken) {
this.handleErrorCases(`${propertyNode.name.getText()} property has been identified as optional, which is not cms compliant`);
}
const typeFromNode = this.getTypeFromNode(propertyNode.type, configurationWrapper, source);
res.type = typeFromNode.type;
res.reference = typeFromNode.ref;
res.choices = typeFromNode.choices;
if ((res.type === 'enum' || res.type === 'enum[]') && !res.choices) {
this.handleErrorCases(`${res.name} property should be treated as ENUM but it is not an UnionType nor a TypeReference. This is not cms compliant`);
}
return res;
}
/**
* Extract the possible options in case of an enum node
* @param node Node to extract the data from
* @param source
*/
extractOptionsForEnum(node, source = this.source) {
const options = [];
node.types.forEach((type) => {
if (ts.isLiteralTypeNode(type) && ts.isStringLiteral(type.literal)) {
options.push(type.literal.text);
}
else {
this.handleErrorCases(`${node.getText(source)} is a UnionType that does not have literal elements. This is not cms compliant`);
}
});
return options;
}
/**
* Get the configuration properties from a given interface node
* @param interfaceNode Node of a typescript interface
* @param configurationWrapper
* @param source
*/
getPropertiesFromConfigurationInterface(interfaceNode, configurationWrapper, source = this.source) {
let isConfiguration = false;
let runtime;
let name;
const properties = [];
const categoriesOnProps = [];
interfaceNode.forEachChild((node) => {
if (ts.isIdentifier(node)) {
name = node.getText(source);
}
else if (ts.isHeritageClause(node)) {
node.getChildren(source).forEach((extendedInterfaceNode) => {
const content = extendedInterfaceNode.getText(source);
isConfiguration = isConfiguration || this.CONFIGURATION_INTERFACES.some((r) => r.test(content));
if (typeof runtime === 'undefined') {
runtime = new RegExp('AppBuildConfiguration').test(content) ? false : new RegExp('AppRuntimeConfiguration').test(content) || undefined;
}
});
}
else if (isConfiguration && ts.isPropertySignature(node)) {
const property = this.extractPropertySignatureData(node, configurationWrapper, source);
properties.push(property);
if (property.category && !categoriesOnProps.includes(property.category)) {
categoriesOnProps.push(property.category);
}
}
});
const isApplicationConfig = typeof runtime !== 'undefined';
if (isConfiguration) {
this.logger.debug(`Extracted configuration ${name} from interface with properties: ${properties.map((p) => `(${p.name}: ${p.type})`).join(', ')}`);
}
else {
this.logger.debug(`${name} is ignored because it is not a configuration`);
}
const configDocInfo = this.configDocParser.parseConfigDocFromNode(source, interfaceNode);
if (configDocInfo && configDocInfo.categories) {
for (const describedCategory of configDocInfo.categories) {
if (!categoriesOnProps.includes(describedCategory.name)) {
this.handleErrorCases(`Description found for category "${describedCategory.name}" but no property has this category.`);
}
}
}
return isConfiguration && name ? { name, properties, runtime, isApplicationConfig, ...configDocInfo } : undefined;
}
/**
* Extract the default value of a configuration interface
* @param variableNode Typescript node of the default constant implementing the interface
* @param configurationInformationWrapper Configuration object extracted from the interface
*/
getDefaultValueFromConfigurationInterface(variableNode, configurationInformationWrapper) {
variableNode.forEachChild((varNode) => {
if (ts.isVariableDeclarationList(varNode)) {
varNode.forEachChild((declarationNode) => {
if (ts.isVariableDeclaration(declarationNode)) {
let isConfigImplementation = false;
declarationNode.forEachChild((vNode) => {
if (ts.isTypeReferenceNode(vNode)
&& ts.isIdentifier(vNode.typeName)
&& (vNode.typeName.escapedText.toString() === configurationInformationWrapper.configurationInformation.name
|| (vNode.typeName.escapedText.toString() === 'Readonly'
&& vNode.typeArguments?.[0]
&& ts.isTypeReferenceNode(vNode.typeArguments?.[0])
&& ts.isIdentifier(vNode.typeArguments?.[0].typeName)
&& vNode.typeArguments[0].typeName.escapedText.toString() === configurationInformationWrapper.configurationInformation.name))) {
isConfigImplementation = true;
}
else if (isConfigImplementation
&& (ts.isObjectLiteralExpression(vNode)
|| (ts.isAsExpression(vNode)
&& ts.isTypeReferenceNode(vNode.type)
&& ts.isIdentifier(vNode.type.typeName)
&& vNode.type.typeName.escapedText.toString() === 'const'
&& ts.isObjectLiteralExpression(vNode.expression)))) {
const objectExpression = ts.isObjectLiteralExpression(vNode) ? vNode : vNode.expression;
objectExpression.forEachChild((propertyNode) => {
if (ts.isPropertyAssignment(propertyNode)) {
let identifier;
propertyNode.forEachChild((fieldNode) => {
if (ts.isIdentifier(fieldNode)) {
identifier = fieldNode.getText(this.source);
}
else if (identifier) {
const property = configurationInformationWrapper.configurationInformation.properties.find((prop) => prop.name === identifier);
if (property) {
if (ts.isArrayTypeNode(fieldNode) || ts.isArrayLiteralExpression(fieldNode)) {
property.values = [];
let typeReplacement;
fieldNode.forEachChild((arrayItem) => {
if (ts.isStringLiteral(arrayItem)) {
// Handle string (StringLiteral = 10)
property.values.push(this.removeQuotationMarks(arrayItem.getText(this.source)));
}
else if (ts.isObjectLiteralExpression(arrayItem)
&& property.reference
&& this.isTypedNestedConfiguration(property.reference.name, configurationInformationWrapper, this.libraries)) {
let defaultValuesMapArrayItem = {};
arrayItem.forEachChild((arrayItemProperty) => {
if (ts.isPropertyAssignment(arrayItemProperty) && ts.isStringLiteral(arrayItemProperty.name)) {
arrayItemProperty.name.getText(this.source);
}
// Build the property map pushing all the key/value from the default config
// getChildAt(0, this.source) is the key, getChildAt(1, this.source) the ":" and getChildAt(2, this.source) the value
defaultValuesMapArrayItem = {
...defaultValuesMapArrayItem,
[this.removeQuotationMarks(arrayItemProperty.getChildAt(0, this.source).getText(this.source))]: this.removeQuotationMarks(arrayItemProperty.getChildAt(2, this.source).getText(this.source))
};
});
property.values.push(defaultValuesMapArrayItem);
}
else {
this.logger.warn(`Unsupported type found will be ignored with kind = ${arrayItem.kind} and value = ${arrayItem.getText(this.source)}`);
}
});
if (typeReplacement) {
property.type = typeReplacement;
}
}
else {
if (ts.isStringLiteral(fieldNode) || ts.isNumericLiteral(fieldNode) || fieldNode.kind === ts.SyntaxKind.FalseKeyword || fieldNode.kind === ts.SyntaxKind.TrueKeyword) {
property.value = this.removeQuotationMarks(fieldNode.getText(this.source));
}
else {
this.logger.warn(`Unsupported type found will be ignored with kind = ${fieldNode.kind} and value = ${fieldNode.getText(this.source)}`);
}
}
}
}
});
}
});
}
});
}
});
}
});
return configurationInformationWrapper.configurationInformation;
}
/**
* Remove all quotation marks from the input string to prevent any ""my_string"" and "'my_string'" in the metadata file
* @param inputString that needs to be format
*/
removeQuotationMarks(inputString) {
return inputString.replace(/^["'](.*)["']$/, '$1');
}
/**
* Check is name is typed as a known nested configuration
* @param propertyName
* @param nestedConfiguration List of nested configuration
* @param libraries
*/
isTypedNestedConfiguration(propertyName, nestedConfiguration, libraries) {
if (!nestedConfiguration.configurationInformation) {
return false;
}
// Get the property object associated to propertyName
const property = nestedConfiguration.configurationInformation.properties.find((prop) => prop.reference?.name === propertyName);
if (!property) {
return false;
}
if (property.reference?.library && libraries.includes(property.reference.library)) {
return true;
}
// Extract the type associated to the property
const associatedInterface = property.reference?.name;
// Check if the interface is a known nested configuration
const result = nestedConfiguration.nestedConfiguration.some((configurationInformation) => configurationInformation.name === associatedInterface);
if (!result) {
this.logger.warn(`${propertyName} default value will be ignored because it's not typed as nested configuration`);
}
return result;
}
/**
* This function checks if the interface name given as parameter is extended by the interface node
* @param interfaceDeclaration
* @param extendedInterfaceNames
* @param source
*/
isExtending(interfaceDeclaration, extendedInterfaceNames, source = this.source) {
if (!interfaceDeclaration.heritageClauses) {
return false;
}
return interfaceDeclaration.heritageClauses.some((heritageClause) => {
return heritageClause.types.some((type) => {
return extendedInterfaceNames.some((r) => r.test(type.expression.getText(source)));
});
});
}
/**
* Fill the nested configuration with default values
* @param nestedConfigurationInformation
*/
fillNestedConfigurationDefaultValues(nestedConfigurationInformation) {
if (!nestedConfigurationInformation) {
return;
}
nestedConfigurationInformation.properties.forEach((property) => {
switch (property.type) {
case 'string': {
property.value = '';
break;
}
case 'boolean': {
property.value = 'false';
break;
}
case 'number': {
property.value = '0';
break;
}
case 'enum': {
property.value = property.choices?.[0];
break;
}
}
});
return nestedConfigurationInformation;
}
/**
* Collect nested configuration information
* @param source
*/
collectNestedConfiguration(source) {
const configurationInformationWrapper = { nestedConfiguration: [], unionTypeStringLiteral: [] };
source.forEachChild((node) => {
if (ts.isTypeAliasDeclaration(node) && ts.isUnionTypeNode(node.type) && this.hasStringElements(node.type)) {
configurationInformationWrapper.unionTypeStringLiteral.push({
name: node.name.getText(source),
choices: this.extractOptionsForEnum(node.type, source)
});
}
if (ts.isInterfaceDeclaration(node) // If it extends NestedConfiguration, we consider it as an independent NestedConfig
&& this.isExtending(node, [/NestedConfiguration/], source)) {
let nestedConfigurationInformation = this.getPropertiesFromConfigurationInterface(node, configurationInformationWrapper, source);
nestedConfigurationInformation = this.fillNestedConfigurationDefaultValues(nestedConfigurationInformation);
if (nestedConfigurationInformation) {
// We add it to the list of Nested config if the result is not undefined
configurationInformationWrapper.nestedConfiguration.push(nestedConfigurationInformation);
}
}
});
return configurationInformationWrapper;
}
/**
* Extract the configuration of a typescript file
*/
extract() {
this.logger.debug(`Parsing configuration from ${this.filePath}`);
let configInterfaceFound = false;
// First Iteration to collect the nested configuration interfaces
const configurationInformationWrapper = this.collectNestedConfiguration(this.source);
// Here the source represent the structure that has been extracted from a single file
// Each child represents a part of the file parsed and interpreted
// Keep in mind that the ORDER is very important here, the default values need to be declared AFTER the config interface
// If we have the need to improve that in the future, we could split in several iterations (probably a few ms added to the parsing time)
this.source.forEachChild((node) => {
if (ts.isInterfaceDeclaration(node)) {
// If it extends Configuration, we consider that it's the config interface
if (!configurationInformationWrapper.configurationInformation && this.isExtending(node, this.COMPONENT_CONFIGURATION_INTERFACES)) {
configInterfaceFound = true;
// If the result of getPropertiesFromConfigurationInterface is undefined, we continue to loop on children
configurationInformationWrapper.configurationInformation = this.getPropertiesFromConfigurationInterface(node, configurationInformationWrapper);
}
// If the config interface have been found, we need to look for the default value in the next child
}
else if (configInterfaceFound && configurationInformationWrapper.configurationInformation && ts.isVariableStatement(node)) {
configurationInformationWrapper.configurationInformation = this.getDefaultValueFromConfigurationInterface(node, configurationInformationWrapper);
}
});
return configurationInformationWrapper;
}
}
exports.ComponentConfigExtractor = ComponentConfigExtractor;
//# sourceMappingURL=component-config.extractor.js.map
;