/** * This Node script generates TS type definitions for our document models. */ /* tslint:disable:no-var-requires no-require-imports no-any prefer-for-of no-parameter-reassignment forin no-console */ import { format } from "typescript-formatter/lib/formatter"; import _ from "lodash"; const fs = require("fs"); const Path = require("path"); const jsdom = require("jsdom"); const {JSDOM} = jsdom; const jsdomInner = new JSDOM(""); const glob = require("glob"); const globPattern = Path.resolve(__dirname, "../../models/src/main/resources/models" + "/**/Domain*.xml"); // Gather all models starting with "Domain" (root-level models) const targetFolder = Path.resolve(__dirname, "../src/main/ts/generated/models"); const prologue = "/* tslint:disable:trailing-comma max-line-length align */\n/* This content was generated automatically from Picus model files. Do not check in or edit directly. */"; const repeatableMarker = "@repeatable"; const documentsNamespace = "Documents"; /** * The structure that we will use to represent the document model in memory: * string -- the field's allowed data type (string, boolean, number, Date); * string[] -- enum: an array containing all allowed enum values * TypeDefinitionMap -- a nested definition of a sub-group. */ interface TypeDefinitionMap { [p: string]: string | string[] | TypeDefinitionMap; } fs.mkdirSync(targetFolder, {recursive: true}); // Create the target directory if it does not yet exist. clearTargetDirectory(); glob(globPattern, (er?: Error, filePaths?: string[]) => { // Go through all files that match our input pattern (globPattern above). const skeletons: { [key: string]: any } = {}; // See -> buildDocumentSkeletonsOutput let output = `export namespace ${documentsNamespace} {`; for (const filePath of filePaths!) { console.log(`Parsing ${filePath}...`); const modelPrefix = splitPath(filePath).pop()!.split(".").slice(0, -1).join("."); // Use the filename without the .xml extension const {root: doc, typeDefinitions} = parseModelFile(filePath); // Swap in included groups from other files const parseTree = walk(doc, modelPrefix); // Parse the model into a TypeDefinitionMap const modelRootName = Object.keys(parseTree)[0]; output += buildDocumentInterfaceOutput({modelPrefix, customTypeDefinitions: typeDefinitions, parseTree, modelIdentifier: modelRootName, filePath}); skeletons[modelPrefix] = `${modelPrefix}: {${extractDocumentSkeletonMap(parseTree)}}`; } output += `}\n`; const outputFileName = `${documentsNamespace}.d.ts`; fs.writeFileSync(Path.resolve(targetFolder, "DocumentSkeletons.ts"), format("DocumentSkeletons.ts", buildDocumentSkeletonsOutput(skeletons))); fs.writeFileSync(Path.resolve(targetFolder, outputFileName), format(outputFileName, buildCompositeDocumentDeclarationOutput(Object.keys(skeletons)) + output)); } ); /** * Generates a type definition map from the given group node. */ function parseInclude(filePath: string): { groupNode: Element, customTypeDefinitions: TypeDefinitionMap } { const content = fs.readFileSync(filePath).toString(); const parser = new jsdomInner.window.DOMParser(); const doc: XMLDocument = parser.parseFromString( content, "text/xml"); const typeDefinitions = getCustomTypeDefinitionsFromModelDocument(doc); const groupNode = doc.documentElement.getElementsByTagName("group")[0]; // We need to replace relative filepaths with absolute ones, relative to the physical location of the current include. const includes = doc.documentElement.getElementsByTagName("include"); for (let i = 0; i < includes.length; i++) { const includeTag: Element = includes.item(i); const includeReference = includeTag.getElementsByTagName("fileReference")[0].childNodes[0]; const resolvedPath = Path.resolve(splitPath(filePath).slice(0, -1).join("/"), includeReference.nodeValue); console.log(`Resolving included filepath ${includeReference.nodeValue} to ${resolvedPath}`); includeReference.nodeValue = resolvedPath; } return {groupNode, customTypeDefinitions: typeDefinitions}; } /** * Generates a map representing custom type definitions in the given model. * @param modelDocument The XMLDocument representing the included data model */ function getCustomTypeDefinitionsFromModelDocument(modelDocument: XMLDocument): TypeDefinitionMap { const typeDefinitionMap: { [key: string]: any } = {}; const definitionsNodes = modelDocument.getElementsByTagName("fieldDataTypeDefinition"); for (let i = 0; i < definitionsNodes.length; i++) { const definitionNode = definitionsNodes[i]; const id = definitionNode.getAttribute("id")!; typeDefinitionMap[id] = getTypeFromNode(definitionNode); } return typeDefinitionMap; } /** * Returns a type definition for the given node */ function getTypeFromNode(node: any, modelPrefix?: string): string | string[] { for (let i = 0; i < node.children.length; i++) { const childNode = node.children[i]; switch (childNode.nodeName) { case "string": case "linebreaksPermitted": return "string"; case "boolean": return "boolean"; case "enumeration": const enumerationValues: string[] = []; const valueNodes = childNode.getElementsByTagName("value"); for (let c = 0; c < valueNodes.length; c++) { enumerationValues.push(valueNodes[c].getAttribute("code")); } return enumerationValues; case "number": return "number"; case "date": return "Date"; case "type": // External type definition. return `${(modelPrefix ? modelPrefix + "." : "")}CustomTypes.${childNode.getAttribute("datatypeDefinitionIdRef")}`; default: } } console.warn(`WARNING: Could not figure out the type for field ${node.getAttribute("id")}`); return "unknown"; } function parseModelFile(filepath: string): { root: Element, typeDefinitions: TypeDefinitionMap } { const data = fs.readFileSync(filepath); const parser = new jsdomInner.window.DOMParser(); const doc: XMLDocument = parser.parseFromString( data.toString(), "text/xml"); // resolve includes const includes = doc.documentElement.getElementsByTagName("include"); const typeDefinitions: { [key: string]: any } = {}; while (includes.length > 0) { const includeTag: Element = includes.item(0); const basePath = splitPath(filepath).slice(0, -1).join("/"); const includeFilePath = Path.resolve(basePath, includeTag.getElementsByTagName("fileReference")[0].childNodes[0].nodeValue!); console.log(`Resolving include in model file ${filepath} to ${includeFilePath}`); const {groupNode: replaceNode, customTypeDefinitions: includeTypeDefinitions} = parseInclude(includeFilePath); for (const key in includeTypeDefinitions) { typeDefinitions[key] = includeTypeDefinitions[key]; } includeTag.replaceWith(replaceNode); } return {root: doc.documentElement, typeDefinitions}; } function walk(node: Node, modelPrefix?: string): TypeDefinitionMap { const path: { [key: string]: any } = {}; for (let i = 0; i < node.childNodes.length; i++) { const childNode = node.childNodes[i] as any; if (childNode.nodeName === "field") { path[childNode.getAttribute("name")] = getTypeFromNode(childNode, modelPrefix); } else if (childNode.nodeName === "group") { const isRepeatable = childNode.getAttribute("max") > 1; const subtree = walk(childNode, modelPrefix); if (isRepeatable) { subtree[repeatableMarker] = repeatableMarker; // mark as repeatable, so we know this will be an array at runtime rather than an object } path[childNode.getAttribute("name")] = subtree; } } return path; } function isArray(typeMapEntry: TypeDefinitionMap | string | string[]): typeMapEntry is string[] { return typeMapEntry instanceof Array; } function isTypeMap(typeMapEntry: TypeDefinitionMap | string | string[]): typeMapEntry is TypeDefinitionMap { return !isArray(typeMapEntry) && typeof typeMapEntry === "object"; } /** * Recursively generates TypeScript declarations from the given TypeDefinitionMap. * These can either go inside of the interface (surrounded by interface { .. }) or into a nested type (surrounded by just { .. }) */ function extractTypings(typeMap: TypeDefinitionMap): string { const line = (map: TypeDefinitionMap, key: string) => { const typeMapEntry = typeMap[key]; if (isArray(typeMapEntry)) { const enumValues = typeMapEntry; if (enumValues.length > 1) { return `${key}?: "${enumValues.join("\" | \"")}"`; } return `${key}?: string`; // no enum values defined for some reason, so we'll just make this a string } else if (typeof typeMap[key] === "string") { // type is right here return `${key}?: ${typeMap[key]}`; } else if (isTypeMap(typeMapEntry)) { const isRepeatable = repeatableMarker in typeMapEntry; let subtree = typeMapEntry; if (isRepeatable) { subtree = _.clone(typeMap[key]) as TypeDefinitionMap; delete subtree[repeatableMarker]; // remove the flag from the tree } return `${key}?: {${extractTypings(subtree)}}` + (isRepeatable ? "[]" : ""); } return; }; return Object.keys(typeMap).map(key => line(typeMap, key)).filter(l => l != undefined).join(";\n") + ";"; } /** * Recursively generates a TypeScript object that represents the document model's empty shell. */ function extractDocumentSkeletonMap(typeMap: TypeDefinitionMap): string { const skeletonMember = (map: TypeDefinitionMap, key: string) => { const typeMapEntry = typeMap[key]; if (isTypeMap(typeMapEntry)) { const isRepeatable = repeatableMarker in typeMapEntry; if (isRepeatable) { return `${key}: []`; } return `${key}: {${extractDocumentSkeletonMap(typeMapEntry)}}`; } else { return undefined; } }; return ` ${Object.keys(typeMap).map(key => skeletonMember(typeMap, key)).filter(skeleton => skeleton != undefined).join(",\n")} `; } /** * Emits a namespace containing custom type definitions declared within the given document model. */ function buildCustomTypeDeclarationBlock(modelPrefix: string, typeDefinitions: { [p: string]: any }) { return ` export namespace ${modelPrefix}.CustomTypes { ${Object.keys(typeDefinitions).map(key => { const definition = typeDefinitions[key]; if (definition instanceof Array) { return `export type ${key} = ${definition.map(enumValue => "\"" + enumValue + "\"").join(" | ")};`; } else { return `export type ${key} = ${definition};`; } }).join("\n")} }`; } /** * Emits a type definition (interface) for a specific document type. */ function buildDocumentInterfaceOutput({customTypeDefinitions, parseTree, modelPrefix, filePath} // tslint:disable-next-line:max-line-length : { modelIdentifier: string; customTypeDefinitions: TypeDefinitionMap; parseTree: TypeDefinitionMap; modelPrefix: string, filePath: string }): string { let customTypeDeclarationBlock = ""; if (Object.keys(customTypeDefinitions).length !== 0) { // Some data models contain custom data type definitions (usually enums). If this model does, we will create a definition block for these definitions inside. customTypeDeclarationBlock = buildCustomTypeDeclarationBlock(modelPrefix, customTypeDefinitions); } const interfaceBlock = `export interface ${modelPrefix}Document extends DataDocument { modelId: "${modelPrefix}"; ${extractTypings(parseTree)} } `; return ` // Generated from ${filePath} ${customTypeDeclarationBlock} ${interfaceBlock}`; } /** * Emits a definition for the general "DataDocument" type, the parent interface of all document model types. */ function buildCompositeDocumentDeclarationOutput(knownModelIds: string[]) { return `${prologue} export type DocumentModelId = ${knownModelIds.length > 0 ? knownModelIds.map(modelId => "\"" + modelId + "\"").join(" | ") : "string"}; export interface DataDocument { id: string; modelId: DocumentModelId; } `; } /** * Emits a typescript file that exports all documents "skeletons". A "skeleton" is defined as an empty shell of a data document of a * certain model which contains an empty object for each nested group in the model, as long as the group is non-repeatable. For repeatable * groups, the field will be initialised with an empty array. This information can be used at runtime to type-safely mutate documents * without having to do excessive "undefined" checks. */ function buildDocumentSkeletonsOutput(skeletons: { [p: string]: any }) { // export skeletons let skeletonsString = `${prologue} export const skeletons = {\n`; for (const key in skeletons) { skeletonsString += skeletons[key] + ",\n"; } skeletonsString += "};\n"; return skeletonsString; } /** * Clears files in the target (output) directory. */ function clearTargetDirectory() { console.log(`Cleaning up files in ${targetFolder}`); const filesToDelete: string[] = []; fs.readdir(targetFolder, (er?: Error, files?: string[]) => { if (er) { throw er; } for (const file of files!) { if (file.split(".").pop?.()?.toLowerCase() === "ts") { filesToDelete.push(Path.join(targetFolder, file)); } else { // this shouldn't happen! Unexpected files throw new Error(`Found unexpected files in the generated model directory ${targetFolder} (${file}) -- expected empty or *.ts. Aborting.`); } } for (const filePath of filesToDelete) { fs.unlink(filePath, (err?: Error) => { if (err) { throw err; } }); } }); } function splitPath(filePath: string): string[] { return filePath.split(/[\/\\]/); }