Add html escaping
diff --git a/lib/char_reader.dart b/lib/char_reader.dart
index 01f9539..0f3bf98 100644
--- a/lib/char_reader.dart
+++ b/lib/char_reader.dart
@@ -5,7 +5,7 @@
String _source;
Iterator<int> _itr;
int _i, _c;
- int _line = 0, _column = 0;
+ int _line = 1, _column = 1;
_CharReader(String source)
: _source = source,
@@ -38,7 +38,7 @@
if (c == _NEWLINE) {
_line++;
- _column = 0;
+ _column = 1;
} else {
_column++;
}
@@ -51,7 +51,7 @@
String readWhile(bool test(int charCode)) {
if (peek() == _EOF)
- throw new FormatException('Unexpected end of input: $_i');
+ throw new MustacheFormatException('Unexpected end of input.', line, column);
int start = _i;
diff --git a/lib/mustache.dart b/lib/mustache.dart
index e09c055..517940b 100644
--- a/lib/mustache.dart
+++ b/lib/mustache.dart
@@ -6,9 +6,38 @@
/// http://mustache.github.com/mustache.5.html
-String render(String source, values) => new Template(source).render(values);
+// TODO list.
+// moar tests.
+// html escaping.
+// strict mode :
+// - Limit chars allowed in tagnames.
+// - Error on null value. (Leniant mode, just print nothing)
+// Passing functions as values.
+// Async values. Support Stream, and Future values (Separate library).
+// Allow incremental parsing. I.e. read from Stream<String>, return Stream<String>.
+
+/// Returns a [Template] which can be used to render the template with substituted
+/// values.
+/// Throws [FormatException] if the syntax of the source is invalid.
+Template parse(String source) => new _Template(source);
abstract class Template {
- factory Template(String source) => new _Template(source);
+ /// [values] can be a combination of Map, List, String. Any non-String object
+ /// will be converted using toString().
String render(values);
}
+
+/// MustacheFormatException can be used to obtain the line and column numbers
+/// of the token which caused [parse] to fail.
+class MustacheFormatException implements FormatException {
+ final String message;
+
+ /// The 1-based line number of the token where formatting error was found.
+ final int line;
+
+ /// The 1-based column number of the token where formatting error was found.
+ final int column;
+
+ MustacheFormatException(this.message, this.line, this.column);
+ String toString() => message;
+}
diff --git a/lib/scanner.dart b/lib/scanner.dart
index 05d057d..3ab2fac 100644
--- a/lib/scanner.dart
+++ b/lib/scanner.dart
@@ -16,9 +16,9 @@
const int _NEWLINE = 10;
const int _EXCLAIM = 33;
const int _QUOTE = 34;
+const int _APOS = 39;
const int _HASH = 35;
const int _AMP = 38;
-const int _APOS = 39;
const int _FORWARD_SLASH = 47;
const int _LT = 60;
const int _GT = 62;
@@ -59,11 +59,15 @@
_expect(int expectedCharCode) {
int c = _read();
- if (c != expectedCharCode) {
- throw new FormatException('Unexpected character, '
+
+ if (c == _EOF) {
+ throw new MustacheFormatException('Unexpected end of input.', _r.line, _r.column);
+
+ } else if (c != expectedCharCode) {
+ throw new MustacheFormatException('Unexpected character, '
'expected: ${new String.fromCharCode(expectedCharCode)} ($expectedCharCode), '
'was: ${new String.fromCharCode(c)} ($c), '
- 'at: ${_r.line}:${_r.column}');
+ 'at: ${_r.line}:${_r.column}', _r.line, _r.column);
}
}
@@ -113,7 +117,7 @@
switch(_peek()) {
case _EOF:
- throw new FormatException('Unexpected EOF.');
+ throw new MustacheFormatException('Unexpected end of input.', _r.line, _r.column);
// Escaped text {{{ ... }}}
case _OPEN_MUSTACHE:
diff --git a/lib/template.dart b/lib/template.dart
index dae019c..843c9b8 100644
--- a/lib/template.dart
+++ b/lib/template.dart
@@ -29,10 +29,10 @@
} else if (t.type == _CLOSE_SECTION) {
//TODO in strict mode limit characters allowed in tag names.
if (stack.last.value != t.value) {
- throw new FormatException('Mismatched tags, '
- 'expected: ${stack.last.value}, '
- 'was: t.value, '
- 'at: ${t.line}:${t.column}.');
+ throw new MustacheFormatException('Mismatched tag, '
+ "expected: '${stack.last.value}', "
+ "was: '${t.value}', "
+ 'at: ${t.line}:${t.column}.', t.line, t.column);
}
stack.removeLast();
@@ -46,11 +46,19 @@
class _Template implements Template {
_Template(String source)
- : _root = _parse(_scan(source));
+ : _root = _parse(_scan(source)) {
+ _htmlEscapeMap[_AMP] = '&';
+ _htmlEscapeMap[_LT] = '<';
+ _htmlEscapeMap[_GT] = '>';
+ _htmlEscapeMap[_QUOTE] = '"';
+ _htmlEscapeMap[_APOS] = ''';
+ _htmlEscapeMap[_FORWARD_SLASH] = '/';
+ }
final _Node _root;
final ctl = new List(); //TODO StreamController();
final stack = new List();
+ final _htmlEscapeMap = new Map<int, String>();
render(values) {
ctl.clear();
@@ -84,9 +92,14 @@
}
_renderVariable(node) {
- final value = stack.last[node.value]; //TODO optional warning if variable is null or missing.
- final s = _htmlEscape(value.toString());
- ctl.add(s);
+ final value = stack.last[node.value];
+
+ if (value == null) {
+ //FIXME in strict mode throw an error.
+ } else {
+ final s = _htmlEscape(value.toString());
+ ctl.add(s);
+ }
}
_renderSectionWithValue(node, value) {
@@ -103,8 +116,13 @@
_renderSectionWithValue(node, value);
} else if (value == true) {
_renderSectionWithValue(node, {});
+ } else if (value == false) {
+ // Do nothing.
+ } else if (value == null) {
+ // Do nothing.
+ // FIXME in strict mode, log an error.
} else {
- print('boom!'); //FIXME
+ throw new FormatException("Invalid value type for section: '${node.value}', type: ${value.runtimeType}.");
}
}
@@ -117,19 +135,25 @@
}
}
- /*
- escape
-
- & --> &
- < --> <
- > --> >
- " --> "
- ' --> ' ' not recommended because its not in the HTML spec (See: section 24.4.1) ' is in the XML and XHTML specs.
- / --> /
- */
- //TODO
String _htmlEscape(String s) {
- return s;
+ var buffer = new StringBuffer();
+ int startIndex = 0;
+ int i = 0;
+ for (int c in s.runes) {
+ if (c == _AMP
+ || c == _LT
+ || c == _GT
+ || c == _QUOTE
+ || c == _APOS
+ || c == _FORWARD_SLASH) {
+ buffer.write(s.substring(startIndex, i));
+ buffer.write(_htmlEscapeMap[c]);
+ startIndex = i + 1;
+ }
+ i++;
+ }
+ buffer.write(s.substring(startIndex));
+ return buffer.toString();
}
}
diff --git a/test/mustache_test.dart b/test/mustache_test.dart
index 7bba466..5176a48 100644
--- a/test/mustache_test.dart
+++ b/test/mustache_test.dart
@@ -3,12 +3,171 @@
import 'package:unittest/unittest.dart';
import 'package:mustache/mustache.dart';
+const MISMATCHED_TAG = 'Mismatched tag';
+const UNEXPECTED_EOF = 'Unexpected end of input';
+const INVALID_VALUE_SECTION = 'Invalid value type for section';
+
main() {
- test('1', () {
- var output = render(
- '{{#section}}_{{var}}_{{/section}}',
- {"section": {"var": "bob"}}
- );
- expect(output, equals('_bob_'));
+ group('Section', () {
+ test('Map', () {
+ var output = parse('{{#section}}_{{var}}_{{/section}}')
+ .render({"section": {"var": "bob"}});
+ expect(output, equals('_bob_'));
+ });
+ test('List', () {
+ var output = parse('{{#section}}_{{var}}_{{/section}}')
+ .render({"section": [{"var": "bob"}, {"var": "jim"}]});
+ expect(output, equals('_bob__jim_'));
+ });
+ test('Empty List', () {
+ var output = parse('{{#section}}_{{var}}_{{/section}}')
+ .render({"section": []});
+ expect(output, equals(''));
+ });
+ test('False', () {
+ var output = parse('{{#section}}_{{var}}_{{/section}}')
+ .render({"section": false});
+ expect(output, equals(''));
+ });
+ test('Null', () {
+ var output = parse('{{#section}}_{{var}}_{{/section}}')
+ .render({"section": null});
+ expect(output, equals(''));
+ });
+ test('Invalid value', () {
+ var ex = renderFail(
+ '{{#section}}_{{var}}_{{/section}}',
+ {"section": 42});
+ expect(ex is FormatException, isTrue);
+ expect(ex.message, startsWith(INVALID_VALUE_SECTION));
+ });
+ test('True', () {
+ var output = parse('{{#section}}_ok_{{/section}}')
+ .render({"section": true});
+ expect(output, equals('_ok_'));
+ });
+ });
+
+ group('Inverse Section', () {
+ test('Map', () {
+ var output = parse('{{^section}}_{{var}}_{{/section}}')
+ .render({"section": {"var": "bob"}});
+ expect(output, equals(''));
+ });
+ test('List', () {
+ var output = parse('{{^section}}_{{var}}_{{/section}}')
+ .render({"section": [{"var": "bob"}, {"var": "jim"}]});
+ expect(output, equals(''));
+ });
+ test('Empty List', () {
+ var output = parse('{{^section}}_ok_{{/section}}')
+ .render({"section": []});
+ expect(output, equals('_ok_'));
+ });
+ test('False', () {
+ var output = parse('{{^section}}_ok_{{/section}}')
+ .render({"section": false});
+ expect(output, equals('_ok_'));
+ });
+ test('Null', () {
+ var output = parse('{{^section}}_ok_{{/section}}')
+ .render({"section": null});
+ expect(output, equals('_ok_'));
+ });
+ test('Invalid value', () {
+ var ex = renderFail(
+ '{{^section}}_{{var}}_{{/section}}',
+ {"section": 42});
+ expect(ex is FormatException, isTrue);
+ expect(ex.message, startsWith(INVALID_VALUE_SECTION));
+ });
+ test('True', () {
+ var output = parse('{{^section}}_ok_{{/section}}')
+ .render({"section": true});
+ expect(output, equals(''));
+ });
+ });
+
+ group('Html escape', () {
+
+ test('Escape at start', () {
+ var output = parse('_{{var}}_')
+ .render({"var": "&."});
+ expect(output, equals('_&._'));
+ });
+
+ test('Escape at end', () {
+ var output = parse('_{{var}}_')
+ .render({"var": ".&"});
+ expect(output, equals('_.&_'));
+ });
+
+ test('&', () {
+ var output = parse('_{{var}}_')
+ .render({"var": "&"});
+ expect(output, equals('_&_'));
+ });
+
+ test('<', () {
+ var output = parse('_{{var}}_')
+ .render({"var": "<"});
+ expect(output, equals('_<_'));
+ });
+
+ test('>', () {
+ var output = parse('_{{var}}_')
+ .render({"var": ">"});
+ expect(output, equals('_>_'));
+ });
+
+ test('"', () {
+ var output = parse('_{{var}}_')
+ .render({"var": '"'});
+ expect(output, equals('_"_'));
+ });
+
+ test("'", () {
+ var output = parse('_{{var}}_')
+ .render({"var": "'"});
+ expect(output, equals('_'_'));
+ });
+
+ test("/", () {
+ var output = parse('_{{var}}_')
+ .render({"var": "/"});
+ expect(output, equals('_/_'));
+ });
+
+ });
+
+ group('Invalid format', () {
+ test('Mismatched tag', () {
+ var source = '{{#section}}_{{var}}_{{/not_section}}';
+ var ex = renderFail(source, {"section": {"var": "bob"}});
+ expectFail(ex, 1, 25, 'Mismatched tag');
+ });
+ test('Unexpected EOF', () {
+ var source = '{{#section}}_{{var}}_{{/not_section';
+ var ex = renderFail(source, {"section": {"var": "bob"}});
+ expectFail(ex, 1, source.length + 2, UNEXPECTED_EOF);
+ });
});
}
+
+renderFail(source, values) {
+ try {
+ parse(source).render(values);
+ return null;
+ } catch (e) {
+ return e;
+ }
+}
+
+expectFail(ex, int line, int column, [String msgStartsWith]) {
+ expect(ex, isFormatException);
+ expect(ex is MustacheFormatException, isTrue);
+ expect(ex.line, equals(line));
+ expect(ex.column, equals(column));
+ if (msgStartsWith != null)
+ expect(ex.message, startsWith(msgStartsWith));
+}
\ No newline at end of file