diff --git a/src/Sage.Engine.Tests/CompilerTests.cs b/src/Sage.Engine.Tests/CompilerTests.cs index 9b23f94..280fa4b 100644 --- a/src/Sage.Engine.Tests/CompilerTests.cs +++ b/src/Sage.Engine.Tests/CompilerTests.cs @@ -45,7 +45,10 @@ public void TestAssemblyGeneration(string sourceFile) } [Test] + [TestCase("%%=ADD(1,2)=%%", ContentType.Handlebars, "%%=ADD(1,2)=%%")] [TestCase("%%=ADD(1,2)=%%", ContentType.AMPscript, "3")] + [TestCase("{{#if true}}Hello{{/if}}", ContentType.AMPscript, "")] + [TestCase("{{#if true}}Hello{{/if}}", ContentType.Handlebars, "Hello")] public void TestRenderer(string input, ContentType type, string expected) { var content = new EmbeddedContent(input, "TEST", "TEST", 1, type); diff --git a/src/Sage.Engine.Tests/Corpus/Handlebars/eachHelper.subscribercontext.json b/src/Sage.Engine.Tests/Corpus/Handlebars/eachHelper.subscribercontext.json new file mode 100644 index 0000000..a31fbb1 --- /dev/null +++ b/src/Sage.Engine.Tests/Corpus/Handlebars/eachHelper.subscribercontext.json @@ -0,0 +1,10 @@ +{ + "people": [ + { + "name": "Adam" + }, + { + "name": "Howard" + } + ] +} \ No newline at end of file diff --git a/src/Sage.Engine.Tests/Corpus/Handlebars/eachHelper.txt b/src/Sage.Engine.Tests/Corpus/Handlebars/eachHelper.txt new file mode 100644 index 0000000..7618b1e --- /dev/null +++ b/src/Sage.Engine.Tests/Corpus/Handlebars/eachHelper.txt @@ -0,0 +1,6 @@ +=========== +eachHelper +=========== +{{#each people}}

{{name}}

{{/each}} +++++++++++ +

Adam

Howard

\ No newline at end of file diff --git a/src/Sage.Engine.Tests/Corpus/Handlebars/ifHelper.subscribercontext.json b/src/Sage.Engine.Tests/Corpus/Handlebars/ifHelper.subscribercontext.json new file mode 100644 index 0000000..7477b62 --- /dev/null +++ b/src/Sage.Engine.Tests/Corpus/Handlebars/ifHelper.subscribercontext.json @@ -0,0 +1,4 @@ +{ + "isActiveFalse": false, + "isActiveTrue": true +} \ No newline at end of file diff --git a/src/Sage.Engine.Tests/Corpus/Handlebars/ifHelper.txt b/src/Sage.Engine.Tests/Corpus/Handlebars/ifHelper.txt new file mode 100644 index 0000000..5042d29 --- /dev/null +++ b/src/Sage.Engine.Tests/Corpus/Handlebars/ifHelper.txt @@ -0,0 +1,12 @@ +=========== +If True Block +=========== +{{#if isActiveTrue}}hello{{else}}world{{/if}} +++++++++++ +hello +=========== +If False Block +=========== +{{#if isActiveFalse}}hello{{else}}world{{/if}} +++++++++++ +world \ No newline at end of file diff --git a/src/Sage.Engine.Tests/Corpus/Handlebars/stringEncoding.subscribercontext.json b/src/Sage.Engine.Tests/Corpus/Handlebars/stringEncoding.subscribercontext.json new file mode 100644 index 0000000..3a328e6 --- /dev/null +++ b/src/Sage.Engine.Tests/Corpus/Handlebars/stringEncoding.subscribercontext.json @@ -0,0 +1,4 @@ +{ + "titleTags": "First Template

Tags", + "titleNoTags": "First Template No Tags" +} \ No newline at end of file diff --git a/src/Sage.Engine.Tests/Corpus/Handlebars/stringEncoding.txt b/src/Sage.Engine.Tests/Corpus/Handlebars/stringEncoding.txt new file mode 100644 index 0000000..1fc3f49 --- /dev/null +++ b/src/Sage.Engine.Tests/Corpus/Handlebars/stringEncoding.txt @@ -0,0 +1,12 @@ +=========== +Escaping +=========== +{{titleTags}} +++++++++++ +First Template <p> Tags +=========== +No Escaping +=========== +{{titleNoTags}} +++++++++++ +First Template No Tags \ No newline at end of file diff --git a/src/Sage.Engine.Tests/Corpus/Handlebars/unlessHelper.subscribercontext.json b/src/Sage.Engine.Tests/Corpus/Handlebars/unlessHelper.subscribercontext.json new file mode 100644 index 0000000..0d60c84 --- /dev/null +++ b/src/Sage.Engine.Tests/Corpus/Handlebars/unlessHelper.subscribercontext.json @@ -0,0 +1,4 @@ +{ + "isActiveTrue": true, + "isActiveFalse": false +} \ No newline at end of file diff --git a/src/Sage.Engine.Tests/Corpus/Handlebars/unlessHelper.txt b/src/Sage.Engine.Tests/Corpus/Handlebars/unlessHelper.txt new file mode 100644 index 0000000..1037188 --- /dev/null +++ b/src/Sage.Engine.Tests/Corpus/Handlebars/unlessHelper.txt @@ -0,0 +1,11 @@ +=========== +Unless Helper False +=========== +{{#unless isActiveFalse}}hello{{/unless}} +++++++++++ +hello +=========== +Unless Helper True +=========== +{{#unless isActiveTrue}}hello{{/unless}} +++++++++++ diff --git a/src/Sage.Engine.Tests/Corpus/Handlebars/withHelper.subscribercontext.json b/src/Sage.Engine.Tests/Corpus/Handlebars/withHelper.subscribercontext.json new file mode 100644 index 0000000..e05c856 --- /dev/null +++ b/src/Sage.Engine.Tests/Corpus/Handlebars/withHelper.subscribercontext.json @@ -0,0 +1,6 @@ +{ + "primary": { + "name": "Adam" + }, + "name": "Howard" +} \ No newline at end of file diff --git a/src/Sage.Engine.Tests/Corpus/Handlebars/withHelper.txt b/src/Sage.Engine.Tests/Corpus/Handlebars/withHelper.txt new file mode 100644 index 0000000..cd9fd91 --- /dev/null +++ b/src/Sage.Engine.Tests/Corpus/Handlebars/withHelper.txt @@ -0,0 +1,6 @@ +=========== +WithHelper Tests +=========== +{{#with primary}}{{name}}{{/with}} +++++++++++ +Adam \ No newline at end of file diff --git a/src/Sage.Engine.Tests/HandlebarTests.cs b/src/Sage.Engine.Tests/HandlebarTests.cs new file mode 100644 index 0000000..f438f0f --- /dev/null +++ b/src/Sage.Engine.Tests/HandlebarTests.cs @@ -0,0 +1,32 @@ +// Copyright (c) 2022, salesforce.com, inc. +// All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/Apache-2.0 + +namespace Sage.Engine.Tests +{ + using NUnit.Framework; + + ///

+ /// Basic tests for the language features and runtime + /// + public class HandlebarTests : SageTest + { + [Test] + [RuntimeTest("Handlebars")] + public EngineTestResult TestHandlebars(CorpusData test) + { + try + { + var result = TestUtils.GetOutputFromHandlebarTest(_serviceProvider, test); + Assert.That(result.Output, Is.EqualTo(test.Output)); + return result; + } + catch (CompileCodeException e) + { + Assert.Fail(e.Message); + return new EngineTestResult("!"); + } + } + } +} diff --git a/src/Sage.Engine.Tests/Sage.Engine.Tests.csproj b/src/Sage.Engine.Tests/Sage.Engine.Tests.csproj index 9403dd5..1587ed0 100644 --- a/src/Sage.Engine.Tests/Sage.Engine.Tests.csproj +++ b/src/Sage.Engine.Tests/Sage.Engine.Tests.csproj @@ -83,6 +83,36 @@ PreserveNewest + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + PreserveNewest diff --git a/src/Sage.Engine.Tests/TestUtils.cs b/src/Sage.Engine.Tests/TestUtils.cs index d63c6c6..b291495 100644 --- a/src/Sage.Engine.Tests/TestUtils.cs +++ b/src/Sage.Engine.Tests/TestUtils.cs @@ -1,4 +1,4 @@ -// Copyright (c) 2022, salesforce.com, inc. +// Copyright (c) 2022, salesforce.com, inc. // All rights reserved. // SPDX-License-Identifier: Apache-2.0 // For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/Apache-2.0 @@ -6,7 +6,6 @@ using Antlr4.Runtime.Tree; using Microsoft.Extensions.DependencyInjection; using Sage.Engine.Compiler; -using Sage.Engine.Data; using Sage.Engine.Parser; using Sage.Engine.Runtime; @@ -42,6 +41,27 @@ private static RuntimeContext GetTestRuntimeContext(IServiceProvider serviceProv } + /// + /// Uses Handlebars to compile the content instead of the AMPscript compiler + /// + public static EngineTestResult GetOutputFromHandlebarTest(IServiceProvider serviceProvider, CorpusData test) + { + CompilationOptions options = new CompilerOptionsBuilder() + .WithContent(new EmbeddedContent(test.Code, test.FileFriendlyName, test.FileFriendlyName, 1, ContentType.Handlebars)) + .Build(); + + try + { + string result = serviceProvider.GetService() + !.Compile(options, GetTestRuntimeContext(serviceProvider, options, test), test.SubscriberContext); + return new EngineTestResult(result.ReplaceLineEndings("\n").Trim()); + } + catch (Exception) + { + return new EngineTestResult("!"); + } + } + /// /// Executes the engine and gets the expected result from the engine /// @@ -71,4 +91,4 @@ public static EngineTestResult GetOutputFromTest(IServiceProvider serviceProvide return new EngineTestResult("!"); } } -} \ No newline at end of file +} diff --git a/src/Sage.Engine/Content/ContentExtensions.cs b/src/Sage.Engine/Content/ContentExtensions.cs index 595a1bd..76584d9 100644 --- a/src/Sage.Engine/Content/ContentExtensions.cs +++ b/src/Sage.Engine/Content/ContentExtensions.cs @@ -46,6 +46,13 @@ public static void AddLocalDiskContentClient(this IServiceCollection services, A /// public static ContentType InferContentTypeFromFilename(string path) { + string extension = Path.GetExtension(path).ToLowerInvariant(); + + if (extension == ".hbs") + { + return ContentType.Handlebars; + } + return ContentType.AMPscript; } } diff --git a/src/Sage.Engine/Content/IContent.cs b/src/Sage.Engine/Content/IContent.cs index db9dc75..17477c8 100644 --- a/src/Sage.Engine/Content/IContent.cs +++ b/src/Sage.Engine/Content/IContent.cs @@ -10,7 +10,8 @@ namespace Sage.Engine.Compiler /// public enum ContentType { - AMPscript + AMPscript, + Handlebars }; /// diff --git a/src/Sage.Engine/Content/LocalDiskContentClient.cs b/src/Sage.Engine/Content/LocalDiskContentClient.cs index 6ee89a6..c276c30 100644 --- a/src/Sage.Engine/Content/LocalDiskContentClient.cs +++ b/src/Sage.Engine/Content/LocalDiskContentClient.cs @@ -61,6 +61,12 @@ public LocalDiskContentClient( return new LocalFileContent(id, filePath, 1, ContentType.AMPscript); } + filePath = Path.Combine(_options.InputDirectory.FullName, $"{id}.hbs"); + if (File.Exists(filePath)) + { + return new LocalFileContent(id, filePath, 1, ContentType.Handlebars); + } + return null; } } diff --git a/src/Sage.Engine/DependencyInjection/SageDependencyInjectionExtensions.cs b/src/Sage.Engine/DependencyInjection/SageDependencyInjectionExtensions.cs index abe0464..a860857 100644 --- a/src/Sage.Engine/DependencyInjection/SageDependencyInjectionExtensions.cs +++ b/src/Sage.Engine/DependencyInjection/SageDependencyInjectionExtensions.cs @@ -10,6 +10,7 @@ using Sage.Engine.Data.DependencyInjection; using Sage.Engine.Data.Sqlite; using Sage.Engine.Extensions; +using Sage.Engine.Handlebars; namespace Sage.Engine.DependencyInjection { @@ -61,6 +62,7 @@ public static void AddSage( services.AddLocalDiskContentClient(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); } } } diff --git a/src/Sage.Engine/Handlebars/HandlebarsCompiler.cs b/src/Sage.Engine/Handlebars/HandlebarsCompiler.cs new file mode 100644 index 0000000..0ad0e0c --- /dev/null +++ b/src/Sage.Engine/Handlebars/HandlebarsCompiler.cs @@ -0,0 +1,44 @@ +// Copyright (c) 2023, salesforce.com, inc. +// All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/Apache-2.0 + +using HandlebarsDotNet; +using HandlebarsDotNet.Extension.Json; +using Sage.Engine.Compiler; +using Sage.Engine.Runtime; + +namespace Sage.Engine.Handlebars +{ + /// + /// Uses HandlebarsDotNet to compile content to a string. + /// + /// Does not support AMPscript embedded in the content. + /// + internal class HandlebarsCompiler : IHandlebarsCompiler + { + private IHandlebars _handlebar; + + public HandlebarsCompiler() + { + _handlebar = HandlebarsDotNet.Handlebars.Create(); + _handlebar.Configuration.UseJson(); + } + + public bool CanCompile(IContent content) + { + return content.ContentType == ContentType.Handlebars; + } + + public string Compile(CompilationOptions options, RuntimeContext runtimeContext, SubscriberContext? subscriberContext) + { + var template = _handlebar.Compile(options.Content.GetTextReader()); + + StringWriter writer = new StringWriter(); + + template(writer, subscriberContext?.GetRichAttributes()); + + return writer.ToString(); + } + } +} diff --git a/src/Sage.Engine/Handlebars/IHandlebarsCompiler.cs b/src/Sage.Engine/Handlebars/IHandlebarsCompiler.cs new file mode 100644 index 0000000..9de690d --- /dev/null +++ b/src/Sage.Engine/Handlebars/IHandlebarsCompiler.cs @@ -0,0 +1,14 @@ +// Copyright (c) 2023, salesforce.com, inc. +// All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/Apache-2.0 + +namespace Sage.Engine.Handlebars +{ + /// + /// Renders a piece of Handlebars content to a string. + /// + public interface IHandlebarsCompiler : ICompiler + { + } +} diff --git a/src/Sage.Engine/Runtime/RuntimeContext.cs b/src/Sage.Engine/Runtime/RuntimeContext.cs index 693f365..467c57c 100644 --- a/src/Sage.Engine/Runtime/RuntimeContext.cs +++ b/src/Sage.Engine/Runtime/RuntimeContext.cs @@ -49,21 +49,8 @@ public RuntimeContext( _contentBuilderContentClient = provider.GetRequiredService(); _dataExtensionClient = provider.GetRequiredService(); _dataExtensionClient.ConnectAsync().Wait(); - - string subscriberContextFile = - Path.Combine(Path.GetDirectoryName(_rootCompilationOptions.Content.Location), "subscriber.json"); - if (subscriberContext != null) - { - _subscriberContext = subscriberContext; - } - else if (Path.Exists(subscriberContextFile)) - { - _subscriberContext = new SubscriberContext(File.ReadAllText(subscriberContextFile)); - } - else - { - _subscriberContext = new SubscriberContext(null); - } + + _subscriberContext = subscriberContext ?? new SubscriberContext(null); _stackFrame.Push(new StackFrame(_rootCompilationOptions.GeneratedMethodName, _rootCompilationOptions.Content)); } @@ -229,7 +216,6 @@ public SubscriberContext GetSubscriberContext() internal string? CompileAndExecuteReferencedCode(CompilationOptions currentOptions) { - CompileResult compileResult = CSharpCompiler.GenerateAssemblyFromSource(currentOptions); string poppedContext; @@ -237,6 +223,8 @@ public SubscriberContext GetSubscriberContext() try { + + CompileResult compileResult = CSharpCompiler.GenerateAssemblyFromSource(currentOptions); compileResult.Execute(this); } finally diff --git a/src/Sage.Engine/Runtime/SubscriberContext.cs b/src/Sage.Engine/Runtime/SubscriberContext.cs index 547ce36..95f7ccf 100644 --- a/src/Sage.Engine/Runtime/SubscriberContext.cs +++ b/src/Sage.Engine/Runtime/SubscriberContext.cs @@ -14,7 +14,9 @@ namespace Sage.Engine.Runtime /// public class SubscriberContext { - private readonly JsonNode? _context; + // TODO: This should be updated to only parse once + private readonly JsonNode? _contextNode; + private readonly JsonDocument? _contextDocument; public SubscriberContext(string? context) { @@ -27,12 +29,13 @@ public SubscriberContext(string? context) { PropertyNameCaseInsensitive = true }; - _context = JsonNode.Parse(context, options); + _contextNode = JsonNode.Parse(context, options); + _contextDocument = JsonDocument.Parse(context); } public object? GetAttribute(string attributeName) { - var result = _context?[attributeName]; + var result = _contextNode?[attributeName]; if (result == null) { @@ -42,9 +45,20 @@ public SubscriberContext(string? context) return result; } + /// + /// Returns a flat, non-nested set of subscriber attributes as a key/value pair of strings. + /// public Dictionary GetAttributes() { - return JsonSerializer.Deserialize>(_context); + return JsonSerializer.Deserialize>(_contextNode) ?? new Dictionary(); + } + + /// + /// Enables "rich" attributes, which can be complete JSON documents + /// + public JsonDocument? GetRichAttributes() + { + return _contextDocument; } } } diff --git a/src/Sage.Engine/Sage.Engine.csproj b/src/Sage.Engine/Sage.Engine.csproj index 6899e7f..cc90c65 100644 --- a/src/Sage.Engine/Sage.Engine.csproj +++ b/src/Sage.Engine/Sage.Engine.csproj @@ -11,6 +11,8 @@ + + diff --git a/src/Sage.Webhost/Properties/launchSettings.json b/src/Sage.Webhost/Properties/launchSettings.json index cbdb8f1..432acd2 100644 --- a/src/Sage.Webhost/Properties/launchSettings.json +++ b/src/Sage.Webhost/Properties/launchSettings.json @@ -22,14 +22,24 @@ }, "Ampscript ": { "commandName": "Project", - "commandLineArgs": "ampscript --source \"C:\\code\\AmpscriptFiles\\dd4tmp.ampscript\"", - "workingDirectory": "C:\\code\\AmpscriptFiles", + "commandLineArgs": "ampscript --source \"C:\\code\\AmpscriptFiles\\dd4tmp\\dd4tmp.ampscript\"", + "workingDirectory": "C:\\code\\AmpscriptFiles\\dd4tmp", "launchBrowser": true, "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" }, "dotnetRunMessages": true, "applicationUrl": "https://localhost:7194" + }, + "HBS": { + "commandName": "Project", + "commandLineArgs": "ampscript --source \"C:\\code\\AmpscriptFiles\\dd4tmp-hbs\\dd4tmp.hbs\"", + "workingDirectory": "C:\\code\\AmpscriptFiles\\dd4tmp", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:7194" } }, "iisSettings": { diff --git a/src/Sage.Webhost/WebRequestRenderer.cs b/src/Sage.Webhost/WebRequestRenderer.cs index 31ba1c9..ecfda2f 100644 --- a/src/Sage.Webhost/WebRequestRenderer.cs +++ b/src/Sage.Webhost/WebRequestRenderer.cs @@ -1,10 +1,11 @@ -// Copyright (c) 2022, salesforce.com, inc. +// Copyright (c) 2022, salesforce.com, inc. // All rights reserved. // SPDX-License-Identifier: Apache-2.0 // For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/Apache-2.0 using Microsoft.Extensions.Options; using Sage.Engine.Compiler; +using Sage.Engine.Runtime; using Sage.Webhost; namespace Sage.Engine @@ -33,7 +34,14 @@ public string RenderContent(CompilationOptions inputOption) { try { - return _renderer.Render(inputOption); + SubscriberContext context = null; + string subscriberContextFile = Path.Combine(Path.GetDirectoryName(inputOption.Content.Location), "subscriber.json"); + if (Path.Exists(subscriberContextFile)) + { + context = new SubscriberContext(File.ReadAllText(subscriberContextFile)); + } + + return _renderer.Render(inputOption, context); } catch (GenerateCodeException compileException) { diff --git a/src/vscode/package-lock.json b/src/vscode/package-lock.json index 3851009..7298be6 100644 --- a/src/vscode/package-lock.json +++ b/src/vscode/package-lock.json @@ -1,12 +1,12 @@ { "name": "ampscript", - "version": "0.0.6", + "version": "0.0.7", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "ampscript", - "version": "0.0.6", + "version": "0.0.7", "dependencies": { "@vscode/extension-telemetry": "^0.7.7", "fs-extra": "9.1.0", diff --git a/src/vscode/package.json b/src/vscode/package.json index 63979b1..ed255cd 100644 --- a/src/vscode/package.json +++ b/src/vscode/package.json @@ -3,7 +3,7 @@ "publisher": "salesforce", "displayName": "AMPscript Editor", "description": "Locally run AMPscript, powered by AMPscript Core", - "version": "0.0.6", + "version": "0.0.7", "engines": { "vscode": "^1.74.0" }, @@ -39,6 +39,12 @@ ".ampscript" ], "configuration": "./syntaxes/language-configuration.json" + }, + { + "id": "handlebars", + "extensions": [ + ".hbs" + ] } ], "grammars": [ @@ -58,12 +64,13 @@ "type": "ampscript", "languages": [ "ampscript", - "json" + "json", + "handlebars" ], - "label": "Debug AMPscript", + "label": "Debug With AMPscript Core", "configurationSnippets": [ { - "label": "AMPscript: Debug the current AMPscript file", + "label": "AMPscript: Debug the current AMPscript Core file", "description": "Launch an AMPscript file with a debugger.", "body": { "name": "AMPscript launch", diff --git a/src/vscode/src/main.ts b/src/vscode/src/main.ts index 5881c63..cfadede 100644 --- a/src/vscode/src/main.ts +++ b/src/vscode/src/main.ts @@ -78,6 +78,9 @@ export class AmpscriptConfigurationProvider implements vscode.DebugConfiguration } else if (editor.document.languageId === 'ampscript') { config.args = "ampscript --source ${file}"; reporter.sendTelemetryEvent("launch", {'type': 'ampscriptfile'}); + } else if (editor.document.languageId === 'handlebars') { + config.args = "ampscript --source ${file}"; + reporter.sendTelemetryEvent("launch", {'type': 'handlebars'}); } }