UNPKG

@udraft/core

Version:

uDraft is a language and stack agnostic code-generation tool that simplifies full-stack development by converting a single YAML file into code for rapid development.

759 lines (665 loc) 24.4 kB
import * as fs from "fs"; import * as path from "path"; import _ from "lodash"; import { cwd } from "process"; import { PipelineCursor, RenderContent, RenderPath } from "../types/renderer"; import { UModule } from "./module"; import { URenderer } from "./renderer"; import { RendererRequiredError } from "../errors/renderer-required-error"; import { terminal as term } from "terminal-kit"; import { UDraftError } from "../errors/udraft-error"; import { parseDocument } from "yaml"; import { UModel } from "./model"; import { UFeature } from "./feature"; import { UAttribute } from "./attribute"; import { _array, _enum, _ref, _rootModule } from "../shortcuts/attributes"; import { UField } from "./field"; import { ParsingError } from "../errors/parsing-error"; import { $attr } from "../shortcuts/queries"; import { JsonAttribute, JsonDraft, JsonFeature, JsonField, JsonModel, JsonModule, } from "../types/json"; export class UDraft { private _modules: UModule[] = []; private _attributes: UAttribute<any>[] = []; private _workingDir?: string; private _renderers: URenderer[] = []; constructor() {} $modules() { return [...this._modules]; } $workingDir() { return this._workingDir ?? ""; } $renderers() { return this._renderers; } $renderer<Type = URenderer>(name: string): Type | null { return (this.$renderers().find((r) => r.$name() == name) as Type) || null; } $requireRenderer<Type extends URenderer>( fromRendererClass: URenderer, name: string ): Type { const renderer = this.$renderer<Type>(name); if (renderer) return renderer; throw new RendererRequiredError(fromRendererClass.$name(), name); } $attributes() { return [...this._attributes]; } attributes(attributes: UAttribute<any>[]) { this.removeAttributes(attributes); this._attributes = this._attributes.concat(attributes); return this; } removeAttributes(attributes: UAttribute<any>[]) { this._attributes = this._attributes.filter( (attribute) => !attributes.some((a) => a.$name() == attribute.$name()) ); return this; } static load(filePath: string) { const ext = path.extname(filePath); const content = fs.readFileSync(filePath, "utf-8"); switch (ext) { case ".yaml": return UDraft.yaml(content); case ".json": return UDraft.json(content); default: throw new UDraftError(`Unsupported file extension: ${ext}`); } } static yaml(yamlDraft: string) { try { const rawDraft = parseDocument(yamlDraft).toJSON(); return UDraft._parse(rawDraft); } catch (e) { if (e instanceof ParsingError) term.red(`[uDraft] Parsing Error: `).red.bold(e.message + "\n"); else if ( ["YAMLParseError", "YAMLWarning"].includes((e as any).name) || e instanceof ReferenceError ) term .red(`[uDraft] Error in YAML file: `) .red.bold(`${(e as any).message}\n`); else throw e; return null; } } static json(jsonDraft: string) { try { const rawDraft = JSON.parse(jsonDraft); return UDraft._parse(rawDraft); } catch (e) { if (e instanceof ParsingError) term.red(`[uDraft] Parsing Error: `).red.bold(e.message + "\n"); else if (e instanceof SyntaxError) term.red(`[uDraft] Error in JSON file: `).red.bold(`${e.stack}\n`); else throw e; return null; } } static _parse(rawDraft: any) { if (!rawDraft?.draft) throw new ParsingError(`No draft found in the file`); const draft = new UDraft(); const simpleTypes = ["string", "number", "int", "float", "boolean", "date"]; const modelTriggers: Record<string, ((model: UModel | null) => void)[]> = {}; const models: Record<string, UModel> = {}; const addModelTrigger = ( modelName: string, trigger: (model: UModel | null) => void ) => { if (models[modelName]) { trigger(models[modelName]); return; } if (!modelTriggers[modelName]) modelTriggers[modelName] = []; modelTriggers[modelName].push(trigger); }; const emitModelUpdate = (modelName: string, model: UModel) => { if (modelTriggers[modelName]) modelTriggers[modelName].forEach((trigger) => trigger(model)); }; const parseCallSignature = (signature: string) => { const match = signature.trim().match(/^\$([^\(]+)(?:\(*([^\)]*)\))*$/); if (!match) return null; return { fn: match[1].trim(), args: (match[2] || "") .split(",") .map((arg) => arg.trim()) .filter((v) => !!v), }; }; const parseFieldSignature = (signature: string) => { const match = signature.trim().match(/^([^\(]+)\[([^\)]+)\]$/); if (!match) return null; return { name: match[1].trim(), type: match[2].trim() }; }; const parseFieldAttributeSignature = (signature: string) => { const match = signature.trim().match(/^([^\(]+)(?:\(*(.+)\))*$/); if (!match) return null; if (match[1].match(/[\(\)]/g)) return null; return { name: match[1].trim(), value: (match[2] || "").trim().replace(/\)$/, "") as any, }; }; const parseModelSignature = (signature: string) => { const match = signature.match(/[\~\+]([^\(]+)\(*([^\)]*)\)*/); if (!match) return null; return { name: match[1].trim(), extends: (match[2] || "") .split(",") .map((arg) => arg.trim()) .filter((arg) => !!arg), }; }; const parseAttribute = (attributeKey: string, rawAttribute: any) => { const match = attributeKey.match(/\/([^\(]+)\(*([^\)]*)\)*/); if (!match) return null; const attributeName = match[1].trim(); const extendsAttributes = (match[2] || "") .split(",") .map((arg) => arg.trim()) .filter((arg) => !!arg) .map((arg) => { const source = models[arg]; if (!source) throw new ParsingError( `Source model ${arg} not found when extending the ${attributeName} attribute` ); const extended = source.$attribute(attributeName); if (!extended) throw new ParsingError( `Attribute ${attributeName} not found in source model ${arg} when extending the ${attributeName} attribute` ); return extended; }); let finalRawAttribute = typeof rawAttribute == "object" ? {} : rawAttribute; const mergeAttributeValue = (value: any) => { return _.mergeWith(finalRawAttribute, value, (objValue, srcValue) => { if (_.isArray(objValue)) { return objValue.concat(srcValue); } }); }; if (extendsAttributes.length > 0) { extendsAttributes.forEach((extended) => { const extendedValue = extended.$value(); if ( typeof extendedValue == "object" && typeof rawAttribute == "object" ) { mergeAttributeValue(extendedValue); } else finalRawAttribute = extendedValue; }); if (typeof rawAttribute == "object") mergeAttributeValue(rawAttribute); return new UAttribute(attributeName, finalRawAttribute); } else return new UAttribute(attributeName, rawAttribute); }; const parseModel = (modelKey: string, rawModel: any) => { const isModel = modelKey[0] == "+"; const isEnum = modelKey[0] == "~"; if (!isModel && !isEnum) return null; const modelSignature = parseModelSignature(modelKey); if (!modelSignature) throw new ParsingError(`Invalid model declaration: ${modelKey}`); const modelName = modelSignature.name; const model = new UModel(modelName); if (isEnum) model.attributes([new UAttribute("enum", rawModel)]); else { const initialFieldNames = Object.keys(rawModel || {}) .map((fieldName) => { const fieldSignature = parseFieldSignature(fieldName); return fieldSignature?.name; }) .filter((name) => !!name); Object.keys(rawModel || {}).forEach((subModelKey: string) => { const subModelData = rawModel[subModelKey]; const attr = parseAttribute(subModelKey, subModelData); if (attr) return model.attributes([attr]); const call = parseCallSignature(subModelKey); if (call) { switch (call.fn) { case "pick": const srcModelName = call.args[0]; let didPick = false; addModelTrigger(srcModelName, (srcModel: UModel | null) => { if (!srcModel) throw new ParsingError( `Source model ${srcModelName} not found when pick fields to ${modelName} model: ${subModelKey}}` ); const fieldsToPick = (subModelData as string[]) .map((fieldName) => { const renameField = fieldName.match(/([^>]+)\>([^>]*)/); if (renameField) return { from: renameField[1].trim(), to: renameField[2].trim(), }; return { from: fieldName, to: fieldName }; }) .filter( (fieldToPick) => !initialFieldNames.includes(fieldToPick.to) ); const pickField = ({ from, to, }: { from: string; to: string; }) => { const srcField = srcModel.$field(from); if (!srcField) throw new ParsingError( `Field ${from} not found in source model ${srcModelName} when picking fields to ${modelName} model: ${subModelKey}}` ); if (from === to) model.fields([srcField]); else model.fields([srcField.$clone(to)]); }; if (!didPick) { fieldsToPick.forEach(pickField); didPick = true; } else { // Refresh picked fields that were not removed fieldsToPick.forEach(({ from, to }) => { if (model.$field(to)) pickField({ from, to }); }); } }); break; case "remove": addModelTrigger(modelName, (updatedModel) => { if (updatedModel) updatedModel.remove( (subModelData as string[]).filter( (fieldToRemove) => !initialFieldNames.includes(fieldToRemove) ) ); }); break; default: throw new ParsingError( `Invalid call inside Model ${modelName}: ${subModelKey}` ); } return; } const signature = parseFieldSignature(subModelKey); if (!signature) throw new ParsingError( `Invalid field signature inside Model ${modelName} : ${subModelKey}` ); let refModelName = ""; if (!simpleTypes.includes(signature.type)) { refModelName = signature.type; if (signature.type[0] == "&") { refModelName = signature.type.slice(1); signature.type = "reference"; } else signature.type = "nested"; } const field = new UField(signature.name, signature.type); (subModelData || []).forEach((attrKey: string) => { const attrSignature = parseFieldAttributeSignature(attrKey); if (!attrSignature) throw new ParsingError( `Invalid field attribute inside field ${subModelKey} from ${modelName} model: ${attrKey}` ); if (attrSignature.value) attrSignature.value = eval(attrSignature.value); else attrSignature.value = null; field.attributes([ new UAttribute(attrSignature.name, attrSignature.value), ]); }); if (refModelName) { addModelTrigger(refModelName, (refModel: UModel | null) => { if (!refModel) throw new ParsingError( `Model ${refModelName} not found to reference inside Model ${modelName}: ${subModelKey}` ); field.attributes([new UAttribute("ref", refModel)]); }); } model.fields([field]); }); modelSignature.extends.forEach((baseModelName) => { let didExtend = false; addModelTrigger(baseModelName, (baseModel: UModel | null) => { if (!baseModel) throw new ParsingError( `Base model ${baseModelName} not found when extending the ${modelName} model: ${modelKey}}` ); if (!didExtend) { model.fields( baseModel .$fields() .filter((f) => !initialFieldNames.includes(f.$name())) ); didExtend = true; } else { // Refresh extended fields that were not removed model.fields( baseModel.$fields().filter((field) => baseModel.$field(field)) ); } emitModelUpdate(modelName, model); }); }); } models[modelName] = model; emitModelUpdate(modelName, model); return model; }; const parseModule = (moduleKey: string, rawModule: any) => { const mod = new UModule(moduleKey); Object.keys(rawModule || {}).forEach((subModKey: string) => { const subModData = rawModule[subModKey]; const attr = parseAttribute(subModKey, subModData); if (attr) return mod.attributes([attr]); const model = parseModel(subModKey, subModData); if (model) return mod.models([model]); const feature = new UFeature(subModKey); mod.features([feature]); Object.keys(subModData || {}).forEach((subFeatKey: string) => { const subFeatData = subModData[subFeatKey]; const featAttr = parseAttribute(subFeatKey, subFeatData); if (featAttr) return feature.attributes([featAttr]); if (subFeatKey == "input") { let didSetInput = false; Object.keys(subFeatData || {}).forEach((subInputKey: string) => { if (didSetInput) return; const subInputData = subFeatData[subInputKey]; const inputModel = parseModel(subInputKey, subInputData); if (inputModel) { feature.input(inputModel); didSetInput = true; } }); if (!didSetInput) addModelTrigger(subFeatData, (inputModel) => { if (!inputModel) throw new ParsingError( `Model ${subFeatData} not found when setting input for ${feature.$name()} feature` ); feature.input(inputModel); }); } if (subFeatKey == "output") { let didSetOutput = false; Object.keys(subFeatData || {}).forEach((subOutputKey: string) => { if (didSetOutput) return; const subOutputData = subFeatData[subOutputKey]; const outputModel = parseModel(subOutputKey, subOutputData); if (outputModel) { feature.output(outputModel); didSetOutput = true; } }); if (!didSetOutput) addModelTrigger(subFeatData, (outputModel) => { if (!outputModel) throw new ParsingError( `Model ${subFeatData} not found when setting output for ${feature.$name()} feature` ); feature.output(outputModel); }); } }); }); return mod; }; Object.keys(rawDraft.draft || {}).forEach((rootKey: string) => { const rootData = rawDraft.draft[rootKey]; const rootAttr = parseAttribute(rootKey, rootData); if (rootAttr) return draft.attributes([rootAttr]); const module = parseModule(rootKey, rootData); if (module) return draft.modules([module]); }); Object.keys(modelTriggers || {}).forEach((modelName) => { if (models[modelName]) return; modelTriggers[modelName].forEach((trigger) => { trigger(null); }); }); return draft; } extends(seed: UDraft) { return this.modules(seed.$modules()); } modules(modules: UModule[]) { this.remove(modules); this._modules = this._modules.concat(modules); return this; } remove(modules: UModule[]) { this._modules = this._modules.filter( (module) => !modules.some((m) => m.$name() == module.$name()) ); return this; } private _goTo(workingDir: string) { term.blue(`[uDraft] Working Directory: `).bold.magenta(`${workingDir}\n`); this._workingDir = workingDir; return this; } private _clear() { term.blue(`[uDraft] Clear Renderers\n`); this._renderers.forEach((renderer) => { renderer.clear(); }); this._renderers = []; return this; } begin(workingDir: string) { return this._pipeline([]).goTo(workingDir); } private _pipeline( renderers: URenderer[], _controls?: { waitFor: Promise<void>; executionError: Promise<void>; start: () => void; error: (err: any) => void; } ): PipelineCursor { if (!_controls) { _controls = { start: () => {}, error: (err) => {}, waitFor: Promise.resolve(), executionError: Promise.resolve(), }; _controls.waitFor = new Promise((resolve, reject) => { _controls!.start = resolve; }); _controls.executionError = new Promise((resolve, reject) => { _controls!.error = reject; }); } const execution = _controls.waitFor .then( () => new Promise<void>(async (resolve, reject) => { try { for (const renderer of renderers) { await this.render(renderer); } resolve(); } catch (err) { reject(err); } }) ) .catch((err) => { if (!_controls) throw err; if (err instanceof UDraftError) { term.red(`[uDraft] Pipeline Error: `).black.bold(err.message + "\n"); } else _controls.error(err); const halt = new Promise<void>(() => {}); return halt; }); const cursor: PipelineCursor = { goTo: (workingDir: string) => { execution.then(() => this._goTo(workingDir)); return cursor; }, clear: () => { execution.then(() => this._clear()); return cursor; }, pipeline: (renderers: URenderer[]) => { return this._pipeline(renderers, { ..._controls, waitFor: execution, }); }, exec: () => { term.blue(`[uDraft] uDraft: `).bold.green(`${$attr(this, "name")}\n`); _controls.start(); return Promise.race([execution, _controls.executionError]).then(() => { term.bold.green(`\n[uDraft] Done!\n\n`); }); }, }; return cursor; } async render(renderer: URenderer) { term.blue(`[uDraft] Rendering: `).bold.yellow(`${renderer.$name()}\n`); await renderer.init(this); const paths: RenderPath[] = renderer.$paths(); const contents: RenderContent[] = []; for (const renderPath of paths) { renderPath.path = renderPath.path.startsWith("/") ? renderPath.path : path.join(cwd(), this.$workingDir(), renderPath.path); const renderDir = path.dirname(renderPath.path); if (!fs.existsSync(renderDir)) fs.mkdirSync(renderDir, { recursive: true }); let content = ""; if (fs.existsSync(renderPath.path)) content = fs.readFileSync(renderPath.path, "utf-8"); contents.push({ key: renderPath.key, content, meta: renderPath.meta, }); } await renderer.run(contents); const modules = renderer.$selection().modules || []; const models = renderer.$selection().models || []; const features = renderer.$selection().features || []; if (modules.length) { term.white(`[uDraft] Selected Modules: `); term.white.bold( modules.map((module) => module.$name()).join(", ") + "\n" ); } if (models.length) { term.white(`[uDraft] Selected Models: `); term.white.bold(models.map((model) => model.$name()).join(", ") + "\n"); } if (features.length) { term.white(`[uDraft] Selected Features: `); term.white.bold( features.map((feature) => feature.$name()).join(", ") + "\n" ); } for (const renderPath of paths) { const output = renderer.$output(renderPath.key); if (output === null) continue; const dir = path.dirname(renderPath.path); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } fs.writeFileSync(renderPath.path, output.content, "utf-8"); term .white(`[uDraft] Output: `) .bold.white(`${renderPath.key} `) .black( `${renderer .$resolveRelativePath(cwd() + "/index.js", renderPath.path) .replace(/^\.\//, "")}\n` ); } this._renderers.push(renderer); return this; } $json(): JsonDraft { const json: JsonDraft = { attributes: [], models: {}, modules: {}, }; const attrToJson = (attr: UAttribute<any>): JsonAttribute => ({ name: attr.$name(), value: attr.$value(), }); const fieldToJson = (field: UField): JsonField => ({ name: field.$name(), type: field.$type(), isArray: !!$attr(field, _array()), ref: $attr(field, _ref())?.$name(), attributes: field .$attributes() .filter((attr) => !["ref", "array"].includes(attr.$name())) .map(attrToJson), }); const modelToJson = (model: UModel): JsonModel => { const enumData = $attr(model, _enum()); const jsonModel: JsonModel = { name: model.$name(), module: $attr(model, _rootModule())?.$name() ?? "", enum: enumData ?? undefined, fields: !enumData ? model.$fields().map(fieldToJson) : undefined, attributes: model .$attributes() .filter((attr) => !["enum", "rootModule"].includes(attr.$name())) .map(attrToJson), }; if (!json.models[model.$name()]) json.models[model.$name()] = jsonModel; return jsonModel; }; this.$modules().forEach((mod) => { const models: Record<string, JsonModel> = {}; const features: Record<string, JsonFeature> = {}; mod.$models().forEach((model) => { if ($attr(mod, _rootModule()) != mod || !!models[model.$name()]) return; models[model.$name()] = modelToJson(model); }); mod.$features().forEach((feature) => { const input = feature.$input(); const output = feature.$output(); features[feature.$name()] = { name: feature.$name(), attributes: feature.$attributes().map(attrToJson), module: $attr(feature, _rootModule())?.$name() ?? "", input: input ? modelToJson(input) : undefined, output: output ? modelToJson(output) : undefined, }; }); const jsonModule: JsonModule = { name: mod.$name(), attributes: mod.$attributes().map(attrToJson), features, models, }; json.modules[mod.$name()] = jsonModule; }); return json; } }