Skip to content

Commit

Permalink
Intoruce new "import" (#632)
Browse files Browse the repository at this point in the history
  • Loading branch information
gebolze authored Mar 18, 2024
1 parent 0c24ad2 commit e9cd780
Show file tree
Hide file tree
Showing 4 changed files with 261 additions and 1 deletion.
125 changes: 125 additions & 0 deletions Fluid.Tests/FromStatementTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
using System.Collections.Generic;
using System.IO;
using System.Text.Encodings.Web;
using System.Threading.Tasks;
using Fluid.Ast;
using Fluid.Tests.Mocks;
using Fluid.Values;
using Xunit;

namespace Fluid.Tests;

public class FromStatementTests
{
#if COMPILED
private static FluidParser _parser = new FluidParser(new FluidParserOptions { AllowFunctions = true }).Compile();
#else
private static FluidParser _parser = new FluidParser(new FluidParserOptions { AllowFunctions = true });
#endif

[Fact]
public async Task FromStatement_ShouldThrowFileNotFoundException_IfTheFileProviderIsNotPresent()
{
var expression = new LiteralExpression(new StringValue("_Macros.liquid"));
var sw = new StringWriter();

try
{
var fromStatement = new FromStatement(_parser, expression, new List<string> { "foo" });
await fromStatement.WriteToAsync(sw, HtmlEncoder.Default, new TemplateContext());

Assert.True(false);
}
catch (FileNotFoundException)
{
return;
}
}

[Fact]
public async Task FromStatement_ShouldOnlyImportListedMacrosToLocalScope()
{
var expression = new LiteralExpression(new StringValue("_Macros.liquid"));
var sw = new StringWriter();

var fileProvider = new MockFileProvider();
fileProvider.Add("_Macros.liquid", @"
{% macro hello_world() %}
Hello world!
{% endmacro %}
{% macro hello(first, last='Smith') %}
Hello {{first | capitalize}} {{last}}!
{% endmacro %}
");

var options = new TemplateOptions { FileProvider = fileProvider };
var context = new TemplateContext(options);

var fromStatement = new FromStatement(_parser, expression, new List<string>{"hello_world"});
await fromStatement.WriteToAsync(sw, HtmlEncoder.Default, context);

Assert.IsType<FunctionValue>(context.GetValue("hello_world"));
Assert.IsType<NilValue>(context.GetValue("hello"));
}

[Fact]
public async Task FromStatement_ShouldNotRenderAnyOutput()
{
var expression = new LiteralExpression(new StringValue("_Macros.liquid"));
var sw = new StringWriter();

var fileProvider = new MockFileProvider();
fileProvider.Add("_Macros.liquid", @"
{% macro hello_world() %}
Hello world!
{% endmacro %}
{% macro hello(first, last='Smith') %}
Hello {{first | capitalize}} {{last}}!
{% endmacro %}
{{ hello_world() }}
");

var options = new TemplateOptions { FileProvider = fileProvider };
var context = new TemplateContext(options);

var fromStatement = new FromStatement(_parser, expression, new List<string> { "hello_world" });
await fromStatement.WriteToAsync(sw, HtmlEncoder.Default, context);

var result = sw.ToString();
Assert.Equal("", result);
}

[Fact]
public async Task FromStatement_ShouldInvokeImportedMarcos()
{
var expression = new LiteralExpression(new StringValue("_Macros.liquid"));
var sw = new StringWriter();

var fileProvider = new MockFileProvider();
fileProvider.Add("_Macros.liquid", @"
{%- macro hello_world() -%}
Hello world!
{%- endmacro -%}
{%- macro hello(first, last='Doe') -%}
Hello {{first | capitalize}} {{last}}!
{%- endmacro -%}
");

var source = @"
{%- from '_Macros' import hello_world, hello -%}
{{ hello_world() }} {{ hello('John') }}";


_parser.TryParse(source, out var template, out var error);

var options = new TemplateOptions { FileProvider = fileProvider };
var context = new TemplateContext(options);

var result = await template.RenderAsync(context);
Assert.Equal("Hello world! Hello John Doe!", result);
}
}
115 changes: 115 additions & 0 deletions Fluid/Ast/FromStatement.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text.Encodings.Web;
using System.Threading;
using System.Threading.Tasks;
using Fluid.Values;

namespace Fluid.Ast
{
#pragma warning disable CA1001 // Types that own disposable fields should be disposable
public sealed class FromStatement : Statement
#pragma warning restore CA1001
{
public const string ViewExtension = ".liquid";

private readonly FluidParser _parser;
private volatile CachedTemplate _cachedTemplate;
private readonly SemaphoreSlim _semaphore = new SemaphoreSlim(1);

public FromStatement(FluidParser parser, Expression path, List<string> functions = null)
{
_parser = parser;
Path = path;
Functions = functions;
}

public Expression Path { get; }
public List<String> Functions { get; }

public override async ValueTask<Completion> WriteToAsync(TextWriter writer, TextEncoder encoder, TemplateContext context)
{
var relativePath = (await Path.EvaluateAsync(context)).ToStringValue();
if (!relativePath.EndsWith(ViewExtension, StringComparison.OrdinalIgnoreCase))
{
relativePath += ViewExtension;
}

if (_cachedTemplate == null || !string.Equals(_cachedTemplate.Name, System.IO.Path.GetFileNameWithoutExtension(relativePath), StringComparison.Ordinal))
{
await _semaphore.WaitAsync();
try
{
var fileProvider = context.Options.FileProvider;
var fileInfo = fileProvider.GetFileInfo(relativePath);
if (fileInfo == null || !fileInfo.Exists)
{
throw new FileNotFoundException(relativePath);
}

var content = "";

using (var stream = fileInfo.CreateReadStream())
using (var streamReader = new StreamReader(stream))
{
content = await streamReader.ReadToEndAsync();
}

if (!_parser.TryParse(content, out var template, out var errors))
{
throw new ParseException(errors);
}

var identifier = System.IO.Path.GetFileNameWithoutExtension(relativePath);
_cachedTemplate = new CachedTemplate(template, identifier);
}
finally
{
_semaphore.Release();
}
}

try
{
var parentScope = context.LocalScope;

// Create a dedicated scope so we can list all macros defined in this template
context.EnterChildScope();
await _cachedTemplate.Template.RenderAsync(TextWriter.Null, encoder, context);

if (Functions != null)
{
foreach (var functionName in Functions)
{
var value = context.LocalScope.GetValue(functionName);
if (value is FunctionValue)
{
parentScope.SetValue(functionName, value);
}
}
}
else
{
foreach (var property in context.LocalScope.Properties)
{
var value = context.LocalScope.GetValue(property);
if (value is FunctionValue)
{
parentScope.SetValue(property, value);
}
}
}
}
finally
{
context.ReleaseScope();
}


return Completion.Normal;
}

private sealed record CachedTemplate(IFluidTemplate Template, string Name);
}
}
9 changes: 9 additions & 0 deletions Fluid/FluidParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,14 @@ public FluidParser(FluidParserOptions parserOptions)
.ElseError("Invalid 'include' tag")
;

var FromTag = OneOf(
Primary.AndSkip(Terms.Text("import")).And(Separated(Comma, Identifier)).Then(x => new FromStatement(this, x.Item1, x.Item2)),
Primary.Then(x => new FromStatement(this, x))
).AndSkip(TagEnd)
.Then<Statement>(x => x)
.ElseError("Invalid 'from' tag")
;

var StringAfterRender = String.ElseError(ErrorMessages.ExpectedStringRender);

var RenderTag = OneOf(
Expand Down Expand Up @@ -436,6 +444,7 @@ public FluidParser(FluidParserOptions parserOptions)
if (parserOptions.AllowFunctions)
{
RegisteredTags["macro"] = MacroTag;
RegisteredTags["from"] = FromTag;
}

[MethodImpl(MethodImplOptions.AggressiveInlining)]
Expand Down
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -934,7 +934,18 @@ Now `field` is available as a local property of the template and can be invoked
{{ field('pass', type='password') }}
```

> Macros need to be defined before they are used as they are discovered as the template is executed. They can also be defined in external templates and imported using the `{% include %}` tag.
> Macros need to be defined before they are used as they are discovered as the template is executed.
### Importing functions from external templates
Macros defined in an external template **must** be imported before they can be invoked.

```
{% from 'forms' import field %}
{{ field('user') }}
{{ field('pass', type='password') }}
```


### Extensibility

Expand Down

0 comments on commit e9cd780

Please sign in to comment.