Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions third_party/packages/mustache_template/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
## 2.1.0

* Adds `onMissingVariable` to support custom rendering for unresolved variable
tags.

## 2.0.4

* Fixes a broken README link to the Mustache manual.
Expand Down
45 changes: 43 additions & 2 deletions third_party/packages/mustache_template/lib/mustache.dart
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,25 @@ abstract class Template {
/// [values] can be a combination of Map, List, String. Any non-String object
/// will be converted using toString(). Null values will cause a
/// [TemplateException], unless lenient module is enabled.
String renderString(Object? values);
///
/// If [onMissingVariable] is provided, it will be called for unresolved
/// variables before throwing an exception.
String renderString(
Object? values, {
MissingVariableCallback? onMissingVariable,
});

/// [values] can be a combination of Map, List, String. Any non-String object
/// will be converted using toString(). Null values will cause a
/// [TemplateException], unless lenient module is enabled.
void render(Object? values, StringSink sink);
///
/// If [onMissingVariable] is provided, it will be called for unresolved
/// variables before throwing an exception.
void render(
Object? values,
StringSink sink, {
MissingVariableCallback? onMissingVariable,
});
}

// TODO(stuartmorgan): Remove this. See https://github.com/flutter/flutter/issues/174722.
Expand All @@ -42,6 +55,34 @@ typedef PartialResolver = Template? Function(String);
// ignore: public_member_api_docs
typedef LambdaFunction = Object Function(LambdaContext context);

/// Called when a variable tag cannot be resolved from the current context.
typedef MissingVariableCallback =
String? Function(String name, MissingVariableContext context);

/// Passed as an argument to a missing variable callback.
class MissingVariableContext {
/// Creates a context for a missing variable callback invocation.
const MissingVariableContext({
required this.templateName,
required this.source,
required this.offset,
required this.htmlEscape,
});

/// The name used to identify the template, as passed to the Template
/// constructor.
final String? templateName;

/// The template source.
final String source;

/// The character offset of the missing variable tag within [source].
final int offset;

/// Whether the missing variable is rendered with HTML escaping.
final bool htmlEscape;
}

/// Passed as an argument to a mustache lambda function. The methods on
/// this object may only be called before the lambda function returns. If a
/// method is called after it has returned an exception will be thrown.
Expand Down
38 changes: 33 additions & 5 deletions third_party/packages/mustache_template/lib/src/renderer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ class Renderer extends Visitor {
List<Object?> stack,
this.lenient,
this.htmlEscapeValues,
this.onMissingVariable,
this.partialResolver,
this.templateName,
this.indent,
Expand All @@ -28,6 +29,7 @@ class Renderer extends Visitor {
ctx._stack,
ctx.lenient,
ctx.htmlEscapeValues,
ctx.onMissingVariable,
ctx.partialResolver,
ctx.templateName,
ctx.indent + indent,
Expand All @@ -40,6 +42,7 @@ class Renderer extends Visitor {
ctx._stack,
ctx.lenient,
ctx.htmlEscapeValues,
ctx.onMissingVariable,
ctx.partialResolver,
ctx.templateName,
ctx.indent,
Expand All @@ -52,6 +55,7 @@ class Renderer extends Visitor {
ctx._stack,
ctx.lenient,
ctx.htmlEscapeValues,
ctx.onMissingVariable,
ctx.partialResolver,
ctx.templateName,
ctx.indent + indent,
Expand All @@ -62,6 +66,7 @@ class Renderer extends Visitor {
final List<Object?> _stack;
final bool lenient;
final bool htmlEscapeValues;
final m.MissingVariableCallback? onMissingVariable;
final m.PartialResolver? partialResolver;
final String? templateName;
final String indent;
Expand Down Expand Up @@ -126,16 +131,39 @@ class Renderer extends Visitor {
}

if (value == noSuchProperty) {
final String? fallbackValue = _resolveMissingVariable(node);
if (fallbackValue != null) {
_renderVariableValue(fallbackValue, node);
return;
}
if (!lenient) {
throw error('Value was missing for variable tag: ${node.name}.', node);
}
} else {
final valueString = (value == null) ? '' : value.toString();
final String output = !node.escape || !htmlEscapeValues
? valueString
: _htmlEscape(valueString);
write(output);
_renderVariableValue(value, node);
}
}

String? _resolveMissingVariable(VariableNode node) {
if (onMissingVariable == null) {
return null;
}

final context = m.MissingVariableContext(
templateName: templateName,
source: source,
offset: node.start,
htmlEscape: node.escape,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The htmlEscape property in MissingVariableContext should accurately reflect whether the rendered output will be HTML-escaped. Currently, it only considers node.escape (whether the tag is {{var}} vs {{{var}}}), but it should also account for the htmlEscapeValues setting of the template. If htmlEscapeValues is false, no escaping is performed regardless of the tag type.

Suggested change
htmlEscape: node.escape,
htmlEscape: node.escape && htmlEscapeValues,

);
return onMissingVariable!(node.name, context);
}

void _renderVariableValue(Object? value, VariableNode node) {
final valueString = (value == null) ? '' : value.toString();
final String output = !node.escape || !htmlEscapeValues
? valueString
: _htmlEscape(valueString);
write(output);
}

@override
Expand Down
14 changes: 11 additions & 3 deletions third_party/packages/mustache_template/lib/src/template.dart
Original file line number Diff line number Diff line change
Expand Up @@ -32,19 +32,27 @@ class Template implements m.Template {
String? get name => _name;

@override
String renderString(Object? values) {
String renderString(
Object? values, {
m.MissingVariableCallback? onMissingVariable,
}) {
final buf = StringBuffer();
render(values, buf);
render(values, buf, onMissingVariable: onMissingVariable);
return buf.toString();
}

@override
void render(Object? values, StringSink sink) {
void render(
Object? values,
StringSink sink, {
m.MissingVariableCallback? onMissingVariable,
}) {
final renderer = Renderer(
sink,
<dynamic>[values],
_lenient,
_htmlEscapeValues,
onMissingVariable,
_partialResolver,
_name,
'',
Expand Down
2 changes: 1 addition & 1 deletion third_party/packages/mustache_template/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: mustache_template
description: A templating library that implements the Mustache template specification
repository: https://github.com/flutter/packages/tree/main/third_party/packages/mustache_template
issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+mustache_template%22
version: 2.0.4
version: 2.1.0

environment:
sdk: ^3.9.0
Expand Down
160 changes: 158 additions & 2 deletions third_party/packages/mustache_template/test/mustache_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,154 @@ void main() {
});
});

group('Missing variable callback', () {
test('renders fallback in strict mode', () {
final String output = Template('_{{missing}}_', name: 'test_template')
.renderString(
<String, Object?>{},
onMissingVariable: (String name, MissingVariableContext context) {
expect(name, equals('missing'));
expect(context.htmlEscape, isTrue);
expect(context.templateName, equals('test_template'));
expect(context.offset, equals(1));
return 'bob';
},
);
expect(output, equals('_bob_'));
});

test('null fallback preserves strict exception behavior', () {
final Exception? ex = renderFail(
'{{missing}}',
<String, Object?>{},
onMissingVariable: (String name, MissingVariableContext context) =>
null,
);
expectFail(ex, null, null, VALUE_MISSING);
});

test('runs before lenient handling', () {
final String output = parse('_{{missing}}_', lenient: true).renderString(
<String, Object?>{},
onMissingVariable: (String name, MissingVariableContext context) =>
'bob',
);
expect(output, equals('_bob_'));
});

test('null fallback preserves lenient behavior', () {
final String output = parse('_{{missing}}_', lenient: true).renderString(
<String, Object?>{},
onMissingVariable: (String name, MissingVariableContext context) =>
null,
);
expect(output, equals('__'));
});

test('null values do not invoke callback', () {
var called = false;
final String output = Template('_{{var}}_').renderString(
<String, Object?>{'var': null},
onMissingVariable: (String name, MissingVariableContext context) {
called = true;
return 'bob';
},
);
expect(output, equals('__'));
expect(called, isFalse);
});

test('applies html escaping to callback output', () {
final String escapedOutput = parse('_{{missing}}_').renderString(
<String, Object?>{},
onMissingVariable: (String name, MissingVariableContext context) {
expect(context.htmlEscape, isTrue);
return '&';
},
);
final String unescapedOutput = parse('_{{{missing}}}_').renderString(
<String, Object?>{},
onMissingVariable: (String name, MissingVariableContext context) {
expect(context.htmlEscape, isFalse);
return '&';
},
);
expect(escapedOutput, equals('_&amp;_'));
expect(unescapedOutput, equals('_&_'));
});

test('provides full dotted name', () {
const fallback = 'bob';
final String output = parse('{{#section}}{{missing.name}}{{/section}}')
.renderString(
<String, Object>{
'section': <String, String>{'fallback': fallback},
},
onMissingVariable: (String name, MissingVariableContext context) {
expect(name, equals('missing.name'));
return fallback;
},
);
expect(output, equals(fallback));
});

test('provides full name for array index misses', () {
const fallback = 'bob';
final String output = parse('{{#section}}{{items.9}}{{/section}}')
.renderString(
<String, Object>{
'section': <String, Object>{
'items': <String>[],
'fallback': fallback,
},
},
onMissingVariable: (String name, MissingVariableContext context) {
expect(name, equals('items.9'));
return fallback;
},
);
expect(output, equals(fallback));
});

test('propagates to partial rendering', () {
const fallback = 'bob';
final templates = <String, Template>{};
Template? resolver(String name) => templates[name];
templates['partial'] = Template('{{missing}}', partialResolver: resolver);
final template = Template('{{>partial}}', partialResolver: resolver);
final String output = template.renderString(
<String, String>{'fallback': fallback},
onMissingVariable: (String name, MissingVariableContext context) =>
fallback,
);
expect(output, equals(fallback));
});

test('propagates to lambda renderSource', () {
const fallback = 'bob';
final String output = Template('{{lambda}}').renderString(
<String, Object>{
'fallback': fallback,
'lambda': (LambdaContext ctx) => ctx.renderSource('{{missing}}'),
},
onMissingVariable: (String name, MissingVariableContext context) =>
fallback,
);
expect(output, equals(fallback));
});

test('render writes fallback to sink', () {
final buffer = StringBuffer();
Template('_{{missing}}_').render(
<String, Object?>{},
buffer,
onMissingVariable: (String name, MissingVariableContext context) =>
'bob',
);
expect(buffer.toString(), equals('_bob_'));
});
});

group('Invalid format', () {
test('Mismatched tag', () {
const source = '{{#section}}_{{var}}_{{/notsection}}';
Expand Down Expand Up @@ -909,9 +1057,17 @@ void main() {
});
}

Exception? renderFail(String source, Object values) {
Exception? renderFail(
String source,
Object values, {
bool lenient = false,
MissingVariableCallback? onMissingVariable,
}) {
try {
parse(source).renderString(values);
parse(
source,
lenient: lenient,
).renderString(values, onMissingVariable: onMissingVariable);
return null;
} on Exception catch (e) {
return e;
Expand Down