From 14bead31f7bfe773c1867f369b0180871b6121bf Mon Sep 17 00:00:00 2001 From: Geoffrey Hendrey Date: Fri, 12 Jan 2024 13:01:47 -0800 Subject: [PATCH] Dynamic imports (#40) * rebased onto master * add examples for dynamic import * WIP, at least one test isn't working * tests pass * all tests pass * test++ * test++ * test++ * timerManager restore --------- Co-authored-by: Geoffrey Hendrey --- README.md | 117 +++++++++++++++++++++-------- example/absoluteRoot.json | 4 + example/context.json | 6 ++ example/ex16.json | 2 +- example/experimental/main.yaml | 6 ++ example/experimental/main2.yaml | 12 +++ example/importsAbsoluteRoot.json | 4 + src/CliCore.ts | 105 +++++++++++++------------- src/MetaInfoProducer.ts | 7 +- src/StatedREPL.ts | 4 +- src/TemplateProcessor.ts | 63 ++++++++++------ src/test/TemplateProcessor.test.js | 44 +++++++++++ 12 files changed, 260 insertions(+), 114 deletions(-) create mode 100644 example/absoluteRoot.json create mode 100644 example/context.json create mode 100644 example/experimental/main.yaml create mode 100644 example/experimental/main2.yaml create mode 100644 example/importsAbsoluteRoot.json diff --git a/README.md b/README.md index fbf1df8e..e2b6a0f8 100644 --- a/README.md +++ b/README.md @@ -811,12 +811,36 @@ Individual JSONata programs are embedded in JSON files between `${..}`. What is The input, by default, is the object or array that the expression resides in. For instance in the example **above**, you can see that the JSONata `$` variable refers to the array itself. Therefore, expressions like `$[0]` refer to the first element of the array. ## Rerooting Expressions -In the example below, we want `player1` to refer to `/player1` (the field named 'player1' at the root of the document). -But our expression `greeting & ', ' & player1` is located deep in the document at `/dialog/part1`. So how can we cause -the root of the document to be the input to the JSONata expression `greeting & ', ' & player1`? +In Stated templates, one way to declare a JSONata expression is by surrounding it by "dollars moustaches". +E.g `${...some expression...}`. JSONata expressions always have a [context](https://docs.jsonata.org/programming#built-in-variables). +The `$` variable always points to the current context. The `$$` variable always points to the input (root context) for an +expression. +In a Stated template, the root context for an expression is the object in which the expression is contained. For +Example: +```json +> .init -f "example/context.json" +{ + "a": { + "b": "${[c,' and ',$.c,' and ',$$.c,' are the same thing. $ (current context) is /a, the object in which this expression resides']~>$join}", + "c": "hello" + } +} +> .out +{ + "a": { + "b": "hello and hello and hello are the same thing. $ (current context) is /a, the object in which this expression resides", + "c": "hello" + } +} +``` +Now we will show how we can change the context of an expression using 'rerooting.' Rerooting allows the expression's root +context to be pointed anywhere in the json document. +In the example below, consider `greeting & ', ' & player1'`. We want `player1` to refer to the content at json pointer `/player1` (the field named 'player1' at the root of the document). +But our expression `greeting & ', ' & player1` is located deep in the document at `/dialog/partI`. So how can we cause +the root of the document to be the context for the JSONata expression `greeting & ', ' & player1`? You can reroot an expression in a different part of the document using relative rooting `../${}` syntax or you can root an at the absolute doc root with `/${}`. The example below shows how expressions located below the root object, can -explicitly set their input using the rooting syntax. Both absolute rooting, `/${...}` and relative rooting `../${...}` +explicitly set their context using the rooting syntax. Both absolute rooting, `/${...}` and relative rooting `../${...}` are shown. ```json @@ -852,8 +876,37 @@ are shown. } } } +``` +An advanced rerooting operator is the `//` absolute root operator. The `/` rooting operator, that we showed above, will never allow the expression +to 'escape' outside of the template it was defined in. But what if we intend for a template to be imported into another template +and we expect there to be a variable defined in the other template that we should use? This is where the `//` absolute root +operator can be used. The `//` operator will set the expression context to the absolute root of whatever the final document is +after all imports have been performed. +```json +> .init -f "example/absoluteRoot.json" +{ + "to": "!${'Professor Falken'}", + "greeting": "//${'Hello, ' & to}" +} +> .out +{ + "greeting": "Hello, Professor Falken" +} +> .init -f "example/importsAbsoluteRoot.json" +{ + "to": "Joshua", + "message": "${$import('example/absoluteRoot.json')}" +} +> .out +{ + "to": "Joshua", + "message": { + "greeting": "Hello, Joshua" + } +} ``` + ## DAG Templates can grow complex, and embedded expressions have dependencies on both literal fields and other calculated expressions. stated is at its core a data flow engine. Stated analyzes the abstract syntax tree (AST) of JSONata @@ -1244,41 +1297,41 @@ remote templates (or local literal templates) into the current template } > .note "Now let's use the import function on the template" "=============================================================" -> .init -f "example/ex16.json" +> .init -f example/ex16.json { - "noradCommander": "${ norad.commanderDetails }", - "norad": "${ $fetch('https://raw.githubusercontent.com/geoffhendrey/jsonataplay/main/norad.json') ~> handleRes ~> $import('/norad')}", - "handleRes": "${ function($res){$res.ok? $res.json():{'error': $res.status}} }" + "noradCommander": "${ norad.commanderDetails }", + "norad": "${ $fetch('https://raw.githubusercontent.com/geoffhendrey/jsonataplay/main/norad.json') ~> handleRes ~> $import}", + "handleRes": "${ function($res){$res.ok? $res.json():{'error': $res.status}} }" } > .out { - "noradCommander": { - "fullName": "Jack Beringer", - "salutation": "General Jack Beringer", - "systemsUnderCommand": 4 - }, - "norad": { - "commanderDetails": { + "noradCommander": { "fullName": "Jack Beringer", "salutation": "General Jack Beringer", "systemsUnderCommand": 4 - }, - "organization": "NORAD", - "location": "Cheyenne Mountain Complex, Colorado", - "commander": { - "firstName": "Jack", - "lastName": "Beringer", - "rank": "General" - }, - "purpose": "Provide aerospace warning, air sovereignty, and defense for North America", - "systems": [ - "Ballistic Missile Early Warning System (BMEWS)", - "North Warning System (NWS)", - "Space-Based Infrared System (SBIRS)", - "Cheyenne Mountain Complex" - ] - }, - "handleRes": "{function:}" + }, + "norad": { + "commanderDetails": { + "fullName": "Jack Beringer", + "salutation": "General Jack Beringer", + "systemsUnderCommand": 4 + }, + "organization": "NORAD", + "location": "Cheyenne Mountain Complex, Colorado", + "commander": { + "firstName": "Jack", + "lastName": "Beringer", + "rank": "General" + }, + "purpose": "Provide aerospace warning, air sovereignty, and defense for North America", + "systems": [ + "Ballistic Missile Early Warning System (BMEWS)", + "North Warning System (NWS)", + "Space-Based Infrared System (SBIRS)", + "Cheyenne Mountain Complex" + ] + }, + "handleRes": "{function:}" } > .note "You can see above that 'import' makes it behave as a template, not raw JSON." "=============================================================" diff --git a/example/absoluteRoot.json b/example/absoluteRoot.json new file mode 100644 index 00000000..820c3803 --- /dev/null +++ b/example/absoluteRoot.json @@ -0,0 +1,4 @@ +{ + "to": "!${'Professor Falken'}", + "greeting": "//${'Hello, ' & to}" +} \ No newline at end of file diff --git a/example/context.json b/example/context.json new file mode 100644 index 00000000..6c03b7ba --- /dev/null +++ b/example/context.json @@ -0,0 +1,6 @@ +{ + "a": { + "b": "${[c,' and ',$.c,' and ',$$.c,' are the same thing. $ (current context) is /a, the object in which this expression resides']~>$join}", + "c": "hello" + } +} \ No newline at end of file diff --git a/example/ex16.json b/example/ex16.json index 9193278c..df9ddc75 100644 --- a/example/ex16.json +++ b/example/ex16.json @@ -1,5 +1,5 @@ { "noradCommander": "${ norad.commanderDetails }", - "norad": "${ $fetch('https://raw.githubusercontent.com/geoffhendrey/jsonataplay/main/norad.json') ~> handleRes ~> $import('/norad')}", + "norad": "${ $fetch('https://raw.githubusercontent.com/geoffhendrey/jsonataplay/main/norad.json') ~> handleRes ~> $import}", "handleRes": "${ function($res){$res.ok? $res.json():{'error': $res.status}} }" } \ No newline at end of file diff --git a/example/experimental/main.yaml b/example/experimental/main.yaml new file mode 100644 index 00000000..8190e9ad --- /dev/null +++ b/example/experimental/main.yaml @@ -0,0 +1,6 @@ +name: Main +SAY_HELLO: "${$fetch('https://raw.githubusercontent.com/geoffhendrey/jsonataplay/main/sayhello.json').json()}" +view: + - "/${SAY_HELLO ~> |props|{'name':'world'}| ~> $import}" + - "/${SAY_HELLO ~> |props|{'name':'Universe'}| ~> $import}" + - "/${SAY_HELLO ~> |props|{'name':'Galaxy'}| ~> $import}" \ No newline at end of file diff --git a/example/experimental/main2.yaml b/example/experimental/main2.yaml new file mode 100644 index 00000000..650f6bec --- /dev/null +++ b/example/experimental/main2.yaml @@ -0,0 +1,12 @@ +name: Main +SAY_HELLO: "${$fetch('https://raw.githubusercontent.com/geoffhendrey/jsonataplay/main/sayhello.json').json()}" +now: "${ function(){$set('/timeString', $Date())} }" +tick: "${ $setInterval(now , 1000) }" +timeString: '' +worldTime: ${ "World! " & timeString } +universeTime: ${ "Universe! " & timeString } +galaxyTime: ${ "Galaxy! " & timeString } +view: + - "../${SAY_HELLO ~> |props|{'name':'${$$.worldTime}'}| ~> $import}" + - "../${SAY_HELLO ~> |props|{'name':'${$$.universeTime}'}| ~> $import}" + - "../${SAY_HELLO ~> |props|{'name':'${$$.galaxyTime}'}| ~> $import}" diff --git a/example/importsAbsoluteRoot.json b/example/importsAbsoluteRoot.json new file mode 100644 index 00000000..339cf3bd --- /dev/null +++ b/example/importsAbsoluteRoot.json @@ -0,0 +1,4 @@ +{ + "to": "Joshua", + "message": "${$import('example/absoluteRoot.json')}" +} \ No newline at end of file diff --git a/src/CliCore.ts b/src/CliCore.ts index e5c14686..00165764 100644 --- a/src/CliCore.ts +++ b/src/CliCore.ts @@ -114,7 +114,7 @@ export default class CliCore { this.templateProcessor.close(); } const parsed = CliCore.parseInitArgs(replCmdInputStr); - const {filepath, tags,oneshot, options, xf:contextFilePath, importPath, tail} = parsed; + const {filepath, tags,oneshot, options, xf:contextFilePath, importPath=this.currentDirectory, tail} = parsed; if(filepath===undefined){ return undefined; } @@ -400,66 +400,65 @@ export default class CliCore { public async open(directory: string = this.currentDirectory) { - if(directory === ""){ - directory = this.currentDirectory; - } + if(directory === ""){ + directory = this.currentDirectory; + } - let files: string[] = undefined; - try { - // Read all files from the directory - files = await fs.promises.readdir(directory); - } catch (error) { - console.log(`Error reading directory ${directory}: ${error}`); - console.log('Changed directory with .cd or .open an/existing/directory'); - this.replServer.displayPrompt(); - return {error: `Error reading directory ${directory}: ${error}`}; - } - // Filter out only .json and .yaml files - const templateFiles: string[] = files.filter(file => file.endsWith('.json') || file.endsWith('.yaml')); - - // Display the list of files to the user - templateFiles.forEach((file, index) => { - console.log(`${index + 1}: ${file}`); - }); - - // Create an instance of AbortController - const ac = new AbortController(); - const { signal } = ac; // Get the AbortSignal from the controller - - // Ask the user to choose a file - this.replServer.question('Enter the number of the file to open (or type "abort" to cancel): ', { signal }, async (answer) => { - // Check if the operation was aborted - if (signal.aborted) { - console.log('File open operation was aborted.'); + let files: string[] = undefined; + try { + // Read all files from the directory + files = await fs.promises.readdir(directory); + } catch (error) { + console.log(`Error reading directory ${directory}: ${error}`); + console.log('Changed directory with .cd or .open an/existing/directory'); this.replServer.displayPrompt(); - return; + return {error: `Error reading directory ${directory}: ${error}`}; } + // Filter out only .json and .yaml files + const templateFiles: string[] = files.filter(file => file.endsWith('.json') || file.endsWith('.yaml')); - const fileIndex = parseInt(answer, 10) - 1; // Convert to zero-based index - if (fileIndex >= 0 && fileIndex < templateFiles.length) { - // User has entered a valid file number; initialize with this file - const filepath = templateFiles[fileIndex]; - try { - const result = await this.init(`-f "${filepath}"`); - console.log(StatedREPL.stringify(result)); - console.log("...try '.out' or 'template.output' to see evaluated template") - } catch (error) { - console.log('Error loading file:', error); + // Display the list of files to the user + templateFiles.forEach((file, index) => { + console.log(`${index + 1}: ${file}`); + }); + + // Create an instance of AbortController + const ac = new AbortController(); + const {signal} = ac; // Get the AbortSignal from the controller + + // Ask the user to choose a file + this.replServer.question('Enter the number of the file to open (or type "abort" to cancel): ', {signal}, async (answer) => { + // Check if the operation was aborted + if (signal.aborted) { + console.log('File open operation was aborted.'); + this.replServer.displayPrompt(); + return; } - } else { - console.log('Invalid file number.'); - } - this.replServer.displayPrompt(); - }); + const fileIndex = parseInt(answer, 10) - 1; // Convert to zero-based index + if (fileIndex >= 0 && fileIndex < templateFiles.length) { + // User has entered a valid file number; initialize with this file + const filepath = templateFiles[fileIndex]; + try { + const result = await this.init(`-f "${filepath}"`); // Adjust this call as per your init method's expected format + console.log(StatedREPL.stringify(result)); + console.log("...try '.out' or 'template.output' to see evaluated template") + } catch (error) { + console.log('Error loading file:', error); + } + } else { + console.log('Invalid file number.'); + } + this.replServer.displayPrompt(); + }); - // Allow the user to type "abort" to cancel the file open operation - this.replServer.once('SIGINT', () => { - ac.abort(); - }); + // Allow the user to type "abort" to cancel the file open operation + this.replServer.once('SIGINT', () => { + ac.abort(); + }); - return "open... (type 'abort' to cancel)"; -} + return "open... (type 'abort' to cancel)"; + } public cd(newDirectory: string) { diff --git a/src/MetaInfoProducer.ts b/src/MetaInfoProducer.ts index 136fcba4..3dcecb13 100644 --- a/src/MetaInfoProducer.ts +++ b/src/MetaInfoProducer.ts @@ -28,7 +28,7 @@ export default class MetaInfoProducer { '\\s*' + // Match optional whitespace '(?:(@(?\\w+))?\\s*)' + // Match the 'tag' like @DEV or @TPC on an expression '(?:(?!)?\\s*)' + // Match the ! symbol which means 'temp variable' - '(?:(?\\/)|(?(\\.\\.\\/)+))?' + // Match a forward slash '/' or '../' to represent relative paths + '(?:(?\\/\\/)|(?\\/)|(?(\\.\\.\\/)+))?' + // Match a forward slash '/' or '../' to represent relative paths '\\$\\{' + // Match the literal characters '${' '(?[\\s\\S]+)' + // Match one or more of any character. This is the JSONata expression/program (including newline, to accommodate multiline JSONata). '\\}' + // Match the literal character '}' @@ -74,9 +74,10 @@ export default class MetaInfoProducer { const keyEndsWithDollars = typeof path[path.length - 1] === 'string' && String(path[path.length - 1]).endsWith('$'); const tag = getMatchGroup('tag'); const exclamationPoint = !!getMatchGroup('tempVariable'); + const leadingSlashSlash = getMatchGroup('slashslash'); const leadingSlash = getMatchGroup('slash'); const leadingCdUp = getMatchGroup('relativePath'); - const slashOrCdUp = leadingSlash || leadingCdUp; + const slashOrCdUp = leadingSlashSlash || leadingSlash || leadingCdUp; const expr = keyEndsWithDollars ? o : getMatchGroup('jsonataExpression'); const hasExpression = !!match || keyEndsWithDollars; @@ -104,7 +105,7 @@ export default class MetaInfoProducer { await getPaths(template); return emit; - /* + /* this is an optimization that may eventually be important to get to // Prune subtrees with treeHasExpressions__ = false const prunedMetaInfos = fullResult.metaInfos.filter(info => info.treeHasExpressions__); diff --git a/src/StatedREPL.ts b/src/StatedREPL.ts index 1404dd02..88ce57b8 100755 --- a/src/StatedREPL.ts +++ b/src/StatedREPL.ts @@ -121,7 +121,9 @@ export default class StatedREPL { console.log(stringify); } } catch (e) { - console.error(e); + const stringify = StatedREPL.stringify(e.message); + console.error(stringify); + result = ""; } this.r.displayPrompt(); } diff --git a/src/TemplateProcessor.ts b/src/TemplateProcessor.ts index d7b9e5cf..5b64f396 100644 --- a/src/TemplateProcessor.ts +++ b/src/TemplateProcessor.ts @@ -17,7 +17,7 @@ import isEqual from "lodash-es/isEqual.js"; import merge from 'lodash-es/merge.js'; import yaml from 'js-yaml'; import Debugger from './Debugger.js'; -import MetaInfoProducer, {JsonPointerString, MetaInfo} from './MetaInfoProducer.js'; +import MetaInfoProducer, {JsonPointerString, JsonPointerStructureArray, MetaInfo} from './MetaInfoProducer.js'; import DependencyFinder from './DependencyFinder.js'; import path from 'path'; import fs from 'fs'; @@ -76,7 +76,8 @@ export default class TemplateProcessor { clearInterval, setTimeout, console, - debounce + debounce, + Date } private static _isNodeJS = typeof process !== 'undefined' && process.release && process.release.name === 'node'; @@ -231,12 +232,21 @@ export default class TemplateProcessor { } } - // Template processor initialize can be called from 2 major use cases - // 1. initialize a new template processor template - // 2. initialize a new template for an existing template processor - // in the second case we need to reset the template processor data holders - public async initialize(template: {} = undefined, jsonPtr = "/") { - this.timerManager.clearAll(); + + /** + * Template processor initialize can be called from 2 major use cases + * 1. initialize a new template processor template + * 2. $import a new template for an existing template processor + * in the second case we need to reset the template processor data holders + * @param template - the object representing the template + * @param jsonPtr - defaults to "/" which is to say, this template is the root template. When we $import a template inside an existing template, then we must provide a path other than root to import into. Typically, we would use the json pointer of the expression where the $import function is used. + * @param templateExprRerooting - When we $import a template may look like `"foo":"../../${x~>|props|{foo:bar}|~>$import}"`. It has `../../` (rerooting) that needs to be pushed into the imported template + * + */ + public async initialize(template: {} = undefined, jsonPtr = "/"):Promise { + if(jsonPtr === "/"){ + this.timerManager.clearAll(); + } // if initialize is called with a template and root json pointer (which is "/" b default) // we need to reset the template. Otherwise, we rely on the one provided in the constructor @@ -356,7 +366,7 @@ export default class TemplateProcessor { private static NOOP = Symbol('NOOP'); - private getImport(jsonPtrIntoTemplate) { //we provide the JSON Pointer that targets where the imported content will go + private getImport(metaInfo: MetaInfo):(templateToImport:string)=>Promise { //we provide the JSON Pointer that targets where the imported content will go //import the template to the location pointed to by jsonPtr return async (importMe) => { let resp; @@ -369,9 +379,12 @@ export default class TemplateProcessor { } else { this.logger.debug(`Attempting local file import of '${importMe}'`); const mightBeAFilename= importMe; - - if (TemplateProcessor._isNodeJS || (typeof BUILD_TARGET !== 'undefined' && BUILD_TARGET !== 'web')) { - resp = await this.localImport(mightBeAFilename); + try { + if (TemplateProcessor._isNodeJS || (typeof BUILD_TARGET !== 'undefined' && BUILD_TARGET !== 'web')) { + resp = await this.localImport(mightBeAFilename); + } + }catch (error){ + this.logger.debug("argument to import doesn't seem to be a file path"); } @@ -381,9 +394,9 @@ export default class TemplateProcessor { } } if(resp === undefined){ - throw new Error(`Import failed for '${importMe}' at '${jsonPtrIntoTemplate}'`); + throw new Error(`Import failed for '${importMe}' at '${metaInfo.jsonPointer__}'`); } - await this.setContentInTemplate(resp, jsonPtrIntoTemplate); + await this.setContentInTemplate(resp, metaInfo); return TemplateProcessor.NOOP; } } @@ -464,9 +477,10 @@ export default class TemplateProcessor { } } - private async setContentInTemplate(response, jsonPtrIntoTemplate) { - jp.set(this.output, jsonPtrIntoTemplate, response); - await this.initialize(response, jsonPtrIntoTemplate); + private async setContentInTemplate(literalTemplateToImport, metaInfo: MetaInfo):Promise { + const jsonPtrIntoTemplate:string = metaInfo.jsonPointer__ as string; + jp.set(this.output, jsonPtrIntoTemplate, literalTemplateToImport); + await this.initialize(literalTemplateToImport, jsonPtrIntoTemplate); //, jp.parse(metaInfo.exprTargetJsonPointer__) } private async createMetaInfos(template, rootJsonPtr = []) { @@ -476,14 +490,15 @@ export default class TemplateProcessor { metaInfo.jsonPointer__ = [...rootJsonPtr, ...metaInfo.jsonPointer__]; metaInfo.exprTargetJsonPointer__ = metaInfo.jsonPointer__.slice(0, -1); const cdUpPath = metaInfo.exprRootPath__; - if (cdUpPath) { const cdUpParts = cdUpPath.match(/\.\.\//g); - if (cdUpParts) { + if (cdUpParts) { // ../../{...} metaInfo.exprTargetJsonPointer__ = metaInfo.exprTargetJsonPointer__.slice(0, -cdUpParts.length); - } else if (cdUpPath.match(/^\/$/g)) { - metaInfo.exprTargetJsonPointer__ = []; - } else { + } else if (cdUpPath.match(/^\/$/g)) { // /${...} + metaInfo.exprTargetJsonPointer__ = rootJsonPtr; + } else if(cdUpPath.match(/^\/\/$/g)){ // //${...} + metaInfo.exprTargetJsonPointer__ = []; //absolute root + } else{ const jsonPtr = jp.compile(metaInfo.jsonPointer__); const msg = `unexpected 'path' expression '${cdUpPath} (see https://github.com/cisco-open/stated#rerooting-expressions)`; const errorObject = {name:'invalidExpRoot', message: msg} @@ -1004,7 +1019,7 @@ export default class TemplateProcessor { private async _evaluateExprNode(jsonPtr) { let evaluated; const metaInfo = jp.get(this.templateMeta, jsonPtr); - const {compiledExpr__, exprTargetJsonPointer__, jsonPointer__, expr__} = metaInfo; + const {compiledExpr__, exprTargetJsonPointer__, expr__} = metaInfo; let target; try { target = jp.get(this.output, exprTargetJsonPointer__); //an expression is always relative to a target @@ -1022,9 +1037,9 @@ export default class TemplateProcessor { evaluated = await compiledExpr__.evaluate( target, {...this.context, - ...{"import": safe(this.getImport(jsonPointer__))}, ...{"errorReport": this.generateErrorReportFunction(metaInfo)}, ...{"defer": safe(this.generateDeferFunction(metaInfo))}, + ...{"import": safe(this.getImport(metaInfo))}, ...jittedFunctions } ); diff --git a/src/test/TemplateProcessor.test.js b/src/test/TemplateProcessor.test.js index 09109d7e..59ea248d 100644 --- a/src/test/TemplateProcessor.test.js +++ b/src/test/TemplateProcessor.test.js @@ -1812,6 +1812,50 @@ test('generateDeferFunction produces correct exception when path is wrong', asyn }); +test("relative vs absolute root '//' in import", async () => { + let template = { + viz:{props:{x:'not hello'}}, + replacementProp: "hello", + b:{ + c:{ + d:"../../${ viz ~> |props|{'x':'../../../../${$$.replacementProp}'}| ~> $import}", + e:"../../${ viz ~> |props|{'x':'//${$$.replacementProp}'}| ~> $import}" + } + } + }; + const tp = new TemplateProcessor(template); + await tp.initialize(); + expect(tp.output.b.c.d).toStrictEqual({props:{x:"hello"}}); + expect(tp.output.b.c.e).toStrictEqual({props:{x:"hello"}}); +}); + +test("root / vs absolute root // inside various rooted expressions", async () => { + let template = { + a: "Global A", + b:{ + c:{ + d: "${ {'a':'Local A', 'b':'/${a}'} ~> $import }", + e: "/${importMe ~>|$|{'b':'/${a}'}| ~> $import}", + f: "../../${importMe ~> |$|{'b':'/${a}'}|~> $import}", + g: "${ {'a':'Local A', 'b':'//${a}'} ~> $import }", + h: "/${importMe ~>|$|{'b':'//${a}'}| ~> $import}", + i: "../../${importMe ~> |$|{'b':'//${a}'}|~> $import}", + } + }, + importMe: {a:'Local A', b:'SOMETHING TO BE REPLACED'} + }; + const tp = new TemplateProcessor(template); + await tp.initialize(); + expect(tp.output.b.c.d.b).toBe("Local A"); + expect(tp.output.b.c.e.b).toBe("Local A"); + expect(tp.output.b.c.f.b).toBe("Local A"); + expect(tp.output.b.c.g.b).toBe("Global A"); + expect(tp.output.b.c.h.b).toBe("Global A"); + expect(tp.output.b.c.i.b).toBe("Global A"); +}); + + +