├── .gitignore ├── .travis.yml ├── CMakeLists.txt ├── README.md ├── qt-mustache.pri ├── qt-mustache.pro ├── src ├── mustache.cpp └── mustache.h └── tests ├── partial.mustache ├── specs ├── comments.json ├── comments.yml ├── delimiters.json ├── delimiters.yml ├── interpolation.json ├── interpolation.yml ├── inverted.json ├── inverted.yml ├── partials.json ├── partials.yml ├── sections.json └── sections.yml ├── test_mustache.cpp └── test_mustache.h /.gitignore: -------------------------------------------------------------------------------- 1 | build-debug 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: cpp 2 | 3 | env: 4 | - QT_SELECT=qt4 5 | - QT_SELECT=qt5 6 | 7 | before_install: 8 | - sudo add-apt-repository --yes ppa:ubuntu-sdk-team/ppa 9 | - sudo apt-get update -qq 10 | - sudo apt-get install -qq libqtcore4 qt4-qmake libqt5core5a qt5-qmake qt5-default qtchooser 11 | 12 | script: 13 | - qmake && make && ./qt-mustache 14 | -------------------------------------------------------------------------------- /CMakeLists.txt: -------------------------------------------------------------------------------- 1 | cmake_minimum_required(VERSION 3.5) 2 | 3 | project(qt-mustache LANGUAGES CXX) 4 | 5 | set(CMAKE_AUTOMOC ON) 6 | 7 | set(QT_MUSTACHE_SOURCES 8 | src/mustache.h 9 | src/mustache.cpp 10 | ) 11 | add_library(${PROJECT_NAME} 12 | ${QT_MUSTACHE_SOURCES} 13 | ) 14 | target_include_directories(${PROJECT_NAME} PUBLIC 15 | $ 16 | ) 17 | 18 | find_package(Qt6 COMPONENTS Core) 19 | if (Qt6_FOUND) 20 | target_link_libraries(${PROJECT_NAME} PRIVATE Qt6::Core) 21 | else() 22 | find_package(Qt5 COMPONENTS Core REQUIRED) 23 | target_link_libraries(${PROJECT_NAME} PRIVATE Qt5::Core) 24 | endif() 25 | 26 | #tests related stuff 27 | if(CMAKE_PROJECT_NAME STREQUAL PROJECT_NAME) 28 | include(CTest) 29 | endif() 30 | 31 | if(CMAKE_PROJECT_NAME STREQUAL PROJECT_NAME AND BUILD_TESTING) 32 | SET(TEST_PROPJECT_NAME "${PROJECT_NAME}_tests") 33 | enable_testing() 34 | 35 | if (Qt6_FOUND) 36 | find_package(Qt6Test REQUIRED) 37 | else() 38 | find_package(Qt5Test REQUIRED) 39 | endif() 40 | 41 | add_executable(${TEST_PROPJECT_NAME} 42 | tests/test_mustache.cpp 43 | tests/test_mustache.h 44 | ) 45 | add_test(${TEST_PROPJECT_NAME} ${TEST_PROPJECT_NAME}) 46 | 47 | if (Qt6_FOUND) 48 | target_link_libraries(${TEST_PROPJECT_NAME} Qt6::Test ${PROJECT_NAME}) 49 | else() 50 | target_link_libraries(${TEST_PROPJECT_NAME} Qt5::Test ${PROJECT_NAME}) 51 | endif() 52 | 53 | file(GLOB TEST_CONTENTS 54 | "tests/specs/*.json" 55 | "tests/partial.mustache" 56 | ) 57 | file(COPY ${TEST_CONTENTS} DESTINATION ${CMAKE_CURRENT_BINARY_DIR}) 58 | endif() 59 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Qt Mustache 2 | 3 | qt-mustache is a simple library for rendering [Mustache templates](https://mustache.github.io/). 4 | 5 | ### Example Usage 6 | 7 | ```cpp 8 | #include "mustache.h" 9 | 10 | QVariantHash contact; 11 | contact["name"] = "John Smith"; 12 | contact["email"] = "john.smith@gmail.com"; 13 | 14 | QString contactTemplate = "{{name}} {{email}}"; 15 | 16 | Mustache::Renderer renderer; 17 | Mustache::QtVariantContext context(contact); 18 | 19 | QTextStream output(stdout); 20 | output << renderer.render(contactTemplate, &context); 21 | ``` 22 | 23 | Outputs: `John Smith john.smith@gmail.com` 24 | 25 | For further examples, see the tests in `test_mustache.cpp` 26 | 27 | ### Building 28 | * To build the tests, run `qmake` followed by `make` 29 | * To use qt-mustache in your project, just add the `mustache.h` and `mustache.cpp` files to your project. 30 | 31 | ### License 32 | qt-mustache is licensed under the BSD license. 33 | 34 | ### Dependencies 35 | qt-mustache depends on the QtCore library. It is compatible with Qt 5 and Qt 6. 36 | 37 | ## Usage 38 | 39 | ### Syntax 40 | 41 | qt-mustache uses the standard Mustache syntax. See the [Mustache manual](https://mustache.github.io/mustache.5.html) for details. 42 | 43 | ### Data Sources 44 | 45 | qt-mustache expands Mustache tags using values from a `Mustache::Context`. `Mustache::QtVariantContext` is a simple 46 | context implementation which wraps a `QVariantHash` or `QVariantMap`. If you want to render a template using a custom data source, 47 | you can either create a `QVariantHash` which mirrors the data source or you can re-implement `Mustache::Context`. 48 | 49 | ### Partials 50 | 51 | When a `{{>partial}}` Mustache tag is encountered, qt-mustache will attempt to load the partial using a `Mustache::PartialResolver` 52 | provided by the context. `Mustache::PartialMap` is a simple resolver which takes a `QHash` map of partial names 53 | to values and looks up partials in that map. `Mustache::PartialFileLoader` is another simple resolver which 54 | fetches partials from `.mustache` files in a specified directory. 55 | 56 | You can re-implement the `Mustache::PartialResolver` interface if you want to load partials from a custom source 57 | (eg. a database). 58 | 59 | ### Error Handling 60 | 61 | If an error occurs when rendering a template, `Mustache::Renderer::errorPosition()` is set to non-negative value and 62 | template rendering stops. If the error occurs whilst rendering a partial template, `errorPartial()` contains the name 63 | of the partial. 64 | 65 | ### Lambdas 66 | 67 | The [Mustache manual](https://mustache.github.io/mustache.5.html) provides a mechanism to customize rendering of 68 | template sections by setting the value for a tag to a callable object (eg. a lambda in Ruby or Javascript), 69 | which takes the unrendered block of text for a template section and renders it itself. qt-mustache supports 70 | this via the `Context::canEval()` and `Context::eval()` methods. 71 | -------------------------------------------------------------------------------- /qt-mustache.pri: -------------------------------------------------------------------------------- 1 | INCLUDEPATH += $$PWD/src 2 | 3 | HEADERS += $$PWD/src/mustache.h 4 | SOURCES += $$PWD/src/mustache.cpp 5 | -------------------------------------------------------------------------------- /qt-mustache.pro: -------------------------------------------------------------------------------- 1 | ###################################################################### 2 | # Automatically generated by qmake (2.01a) Mon Aug 27 11:20:05 2012 3 | ###################################################################### 4 | 5 | TEMPLATE = app 6 | DEPENDPATH += . src tests 7 | INCLUDEPATH += . src tests 8 | QT += testlib 9 | QT -= gui 10 | CONFIG -= app_bundle 11 | 12 | !win32 { 13 | QMAKE_CXXFLAGS += -Werror -Wall -Wextra -Wnon-virtual-dtor 14 | } 15 | 16 | # Input 17 | HEADERS += src/mustache.h tests/test_mustache.h 18 | SOURCES += src/mustache.cpp tests/test_mustache.cpp 19 | 20 | # Copies the given files to the destination directory 21 | defineTest(copyToDestdir) { 22 | files = $$1 23 | 24 | for(FILE, files) { 25 | DDIR = $$OUT_PWD 26 | 27 | # Replace slashes in paths with backslashes for Windows 28 | win32:FILE ~= s,/,\\,g 29 | win32:DDIR ~= s,/,\\,g 30 | 31 | QMAKE_POST_LINK += $$QMAKE_COPY $$quote($$FILE) $$quote($$DDIR) $$escape_expand(\\n\\t) 32 | } 33 | 34 | export(QMAKE_POST_LINK) 35 | } 36 | 37 | copyToDestdir($$PWD/tests/*.mustache) 38 | copyToDestdir($$PWD/tests/specs/*.json) 39 | -------------------------------------------------------------------------------- /src/mustache.cpp: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2012, Robert Knight 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | 10 | Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | */ 14 | 15 | #include "mustache.h" 16 | 17 | #include 18 | #include 19 | #include 20 | #include 21 | 22 | using namespace Mustache; 23 | 24 | QString Mustache::renderTemplate(const QString& templateString, const QVariantHash& args) 25 | { 26 | Mustache::QtVariantContext context(args); 27 | Mustache::Renderer renderer; 28 | return renderer.render(templateString, &context); 29 | } 30 | 31 | QString escapeHtml(const QString& input) 32 | { 33 | QString escaped(input); 34 | for (int i=0; i < escaped.length();) { 35 | const char* replacement = 0; 36 | ushort ch = escaped.at(i).unicode(); 37 | if (ch == '&') { 38 | replacement = "&"; 39 | } else if (ch == '<') { 40 | replacement = "<"; 41 | } else if (ch == '>') { 42 | replacement = ">"; 43 | } else if (ch == '"') { 44 | replacement = """; 45 | } 46 | if (replacement) { 47 | escaped.replace(i, 1, QLatin1String(replacement)); 48 | i += (int)strlen(replacement); 49 | } else { 50 | ++i; 51 | } 52 | } 53 | return escaped; 54 | } 55 | 56 | QString unescapeHtml(const QString& escaped) 57 | { 58 | QString unescaped(escaped); 59 | unescaped.replace(QLatin1String("<"), QLatin1String("<")); 60 | unescaped.replace(QLatin1String(">"), QLatin1String(">")); 61 | unescaped.replace(QLatin1String("""), QLatin1String("\"")); 62 | unescaped.replace(QLatin1String("&"), QLatin1String("&")); 63 | return unescaped; 64 | } 65 | 66 | Context::Context(PartialResolver* resolver) 67 | : m_partialResolver(resolver) 68 | {} 69 | 70 | PartialResolver* Context::partialResolver() const 71 | { 72 | return m_partialResolver; 73 | } 74 | 75 | QString Context::partialValue(const QString& key) const 76 | { 77 | if (!m_partialResolver) { 78 | return QString(); 79 | } 80 | return m_partialResolver->getPartial(key); 81 | } 82 | 83 | bool Context::canEval(const QString&) const 84 | { 85 | return false; 86 | } 87 | 88 | QString Context::eval(const QString& key, const QString& _template, Renderer* renderer) 89 | { 90 | Q_UNUSED(key); 91 | Q_UNUSED(_template); 92 | Q_UNUSED(renderer); 93 | 94 | return QString(); 95 | } 96 | 97 | QtVariantContext::QtVariantContext(const QVariant& root, PartialResolver* resolver) 98 | : Context(resolver) 99 | { 100 | m_contextStack << root; 101 | } 102 | 103 | QVariant variantMapValue(const QVariant& value, const QString& key) 104 | { 105 | if (value.userType() == QMetaType::QVariantMap) { 106 | return value.toMap().value(key); 107 | } else { 108 | return value.toHash().value(key); 109 | } 110 | } 111 | 112 | QVariant variantMapValueForKeyPath(const QVariant& value, const QStringList keyPath) 113 | { 114 | if (keyPath.count() > 1) { 115 | QVariant firstValue = variantMapValue(value, keyPath.first()); 116 | return firstValue.isNull() ? QVariant() : variantMapValueForKeyPath(firstValue, keyPath.mid(1)); 117 | } else if (!keyPath.isEmpty()) { 118 | return variantMapValue(value, keyPath.first()); 119 | } 120 | return QVariant(); 121 | } 122 | 123 | QVariant QtVariantContext::value(const QString& key) const 124 | { 125 | if (key == "." && !m_contextStack.isEmpty()) { 126 | return m_contextStack.last(); 127 | } 128 | QStringList keyPath = key.split("."); 129 | for (int i = m_contextStack.count()-1; i >= 0; i--) { 130 | QVariant value = variantMapValueForKeyPath(m_contextStack.at(i), keyPath); 131 | if (!value.isNull()) { 132 | return value; 133 | } 134 | } 135 | return QVariant(); 136 | } 137 | 138 | bool QtVariantContext::isFalse(const QString& key) const 139 | { 140 | QVariant value = this->value(key); 141 | switch (value.userType()) { 142 | case QMetaType::Double: 143 | case QMetaType::Float: 144 | // QVariant::toBool() rounds floats to the nearest int and then compares 145 | // against 0, which is not the falsiness behavior we want. 146 | return value.toDouble() == 0.; 147 | case QMetaType::QChar: 148 | case QMetaType::Int: 149 | case QMetaType::UInt: 150 | case QMetaType::LongLong: 151 | case QMetaType::ULongLong: 152 | case QMetaType::Bool: 153 | return !value.toBool(); 154 | case QMetaType::QVariantList: 155 | case QMetaType::QStringList: 156 | return value.toList().isEmpty(); 157 | case QMetaType::QVariantHash: 158 | return value.toHash().isEmpty(); 159 | case QMetaType::QVariantMap: 160 | return value.toMap().isEmpty(); 161 | default: 162 | return value.toString().isEmpty(); 163 | } 164 | } 165 | 166 | QString QtVariantContext::stringValue(const QString& key) const 167 | { 168 | return value(key).toString(); 169 | } 170 | 171 | void QtVariantContext::push(const QString& key, int index) 172 | { 173 | QVariant mapItem = value(key); 174 | if (index == -1) { 175 | m_contextStack << mapItem; 176 | } else { 177 | QVariantList list = mapItem.toList(); 178 | m_contextStack << list.value(index, QVariant()); 179 | } 180 | } 181 | 182 | void QtVariantContext::pop() 183 | { 184 | m_contextStack.pop(); 185 | } 186 | 187 | int QtVariantContext::listCount(const QString& key) const 188 | { 189 | const QVariant& item = value(key); 190 | if (item.canConvert() && item.userType() != QMetaType::QString) { 191 | return item.toList().count(); 192 | } 193 | return 0; 194 | } 195 | 196 | bool QtVariantContext::canEval(const QString& key) const 197 | { 198 | return value(key).canConvert(); 199 | } 200 | 201 | QString QtVariantContext::eval(const QString& key, const QString& _template, Renderer* renderer) 202 | { 203 | QVariant fn = value(key); 204 | if (fn.isNull()) { 205 | return QString(); 206 | } 207 | return fn.value()(_template, renderer, this); 208 | } 209 | 210 | PartialMap::PartialMap(const QHash& partials) 211 | : m_partials(partials) 212 | {} 213 | 214 | QString PartialMap::getPartial(const QString& name) 215 | { 216 | return m_partials.value(name); 217 | } 218 | 219 | PartialFileLoader::PartialFileLoader(const QString& basePath) 220 | : m_basePath(basePath) 221 | {} 222 | 223 | QString PartialFileLoader::getPartial(const QString& name) 224 | { 225 | if (!m_cache.contains(name)) { 226 | QString path = m_basePath + '/' + name + ".mustache"; 227 | QFile file(path); 228 | if (file.open(QIODevice::ReadOnly)) { 229 | QTextStream stream(&file); 230 | m_cache.insert(name, stream.readAll()); 231 | } 232 | } 233 | return m_cache.value(name); 234 | } 235 | 236 | Renderer::Renderer() 237 | : m_errorPos(-1) 238 | , m_defaultTagStartMarker("{{") 239 | , m_defaultTagEndMarker("}}") 240 | { 241 | } 242 | 243 | QString Renderer::error() const 244 | { 245 | return m_error; 246 | } 247 | 248 | int Renderer::errorPos() const 249 | { 250 | return m_errorPos; 251 | } 252 | 253 | QString Renderer::errorPartial() const 254 | { 255 | return m_errorPartial; 256 | } 257 | 258 | QString Renderer::render(const QString& _template, Context* context) 259 | { 260 | m_error.clear(); 261 | m_errorPos = -1; 262 | m_errorPartial.clear(); 263 | 264 | m_tagStartMarker = m_defaultTagStartMarker; 265 | m_tagEndMarker = m_defaultTagEndMarker; 266 | 267 | return render(_template, 0, _template.length(), context); 268 | } 269 | 270 | QString Renderer::render(const QString& _template, int startPos, int endPos, Context* context) 271 | { 272 | QString output; 273 | int lastTagEnd = startPos; 274 | 275 | while (m_errorPos == -1) { 276 | Tag tag = findTag(_template, lastTagEnd, endPos); 277 | if (tag.type == Tag::Null) { 278 | output += QStringView(_template).mid(lastTagEnd, endPos - lastTagEnd); 279 | break; 280 | } 281 | output += QStringView(_template).mid(lastTagEnd, tag.start - lastTagEnd); 282 | switch (tag.type) { 283 | case Tag::Value: 284 | { 285 | QString value = context->stringValue(tag.key); 286 | if (tag.escapeMode == Tag::Escape) { 287 | value = escapeHtml(value); 288 | } else if (tag.escapeMode == Tag::Unescape) { 289 | value = unescapeHtml(value); 290 | } 291 | output += value; 292 | lastTagEnd = tag.end; 293 | } 294 | break; 295 | case Tag::SectionStart: 296 | { 297 | Tag endTag = findEndTag(_template, tag, endPos); 298 | if (endTag.type == Tag::Null) { 299 | if (m_errorPos == -1) { 300 | setError("No matching end tag found for section", tag.start); 301 | } 302 | } else { 303 | int listCount = context->listCount(tag.key); 304 | if (listCount > 0) { 305 | for (int i=0; i < listCount; i++) { 306 | context->push(tag.key, i); 307 | output += render(_template, tag.end, endTag.start, context); 308 | context->pop(); 309 | } 310 | } else if (context->canEval(tag.key)) { 311 | output += context->eval(tag.key, _template.mid(tag.end, endTag.start - tag.end), this); 312 | } else if (!context->isFalse(tag.key)) { 313 | context->push(tag.key); 314 | output += render(_template, tag.end, endTag.start, context); 315 | context->pop(); 316 | } 317 | lastTagEnd = endTag.end; 318 | } 319 | } 320 | break; 321 | case Tag::InvertedSectionStart: 322 | { 323 | Tag endTag = findEndTag(_template, tag, endPos); 324 | if (endTag.type == Tag::Null) { 325 | if (m_errorPos == -1) { 326 | setError("No matching end tag found for inverted section", tag.start); 327 | } 328 | } else { 329 | if (context->isFalse(tag.key)) { 330 | output += render(_template, tag.end, endTag.start, context); 331 | } 332 | lastTagEnd = endTag.end; 333 | } 334 | } 335 | break; 336 | case Tag::SectionEnd: 337 | setError("Unexpected end tag", tag.start); 338 | lastTagEnd = tag.end; 339 | break; 340 | case Tag::Partial: 341 | { 342 | QString tagStartMarker = m_tagStartMarker; 343 | QString tagEndMarker = m_tagEndMarker; 344 | 345 | m_tagStartMarker = m_defaultTagStartMarker; 346 | m_tagEndMarker = m_defaultTagEndMarker; 347 | 348 | m_partialStack.push(tag.key); 349 | 350 | QString partialContent = context->partialValue(tag.key); 351 | 352 | // If there is a need to add a special indentation to the partial 353 | if (tag.indentation > 0) { 354 | output += QString(" ").repeated(tag.indentation); 355 | // Indenting the output to keep the parent indentation. 356 | int posOfLF = partialContent.indexOf("\n", 0); 357 | while (posOfLF > 0 && posOfLF < (partialContent.length() - 1)) { // .length() - 1 because we dont want indentation AFTER the last character if it's a LF 358 | partialContent = partialContent.insert(posOfLF + 1, QString(" ").repeated(tag.indentation)); 359 | posOfLF = partialContent.indexOf("\n", posOfLF + 1); 360 | } 361 | } 362 | 363 | QString partialRendered = render(partialContent, 0, partialContent.length(), context); 364 | 365 | output += partialRendered; 366 | 367 | lastTagEnd = tag.end; 368 | 369 | m_partialStack.pop(); 370 | 371 | m_tagStartMarker = tagStartMarker; 372 | m_tagEndMarker = tagEndMarker; 373 | } 374 | break; 375 | case Tag::SetDelimiter: 376 | lastTagEnd = tag.end; 377 | break; 378 | case Tag::Comment: 379 | lastTagEnd = tag.end; 380 | break; 381 | case Tag::Null: 382 | break; 383 | } 384 | } 385 | 386 | return output; 387 | } 388 | 389 | void Renderer::setError(const QString& error, int pos) 390 | { 391 | Q_ASSERT(!error.isEmpty()); 392 | Q_ASSERT(pos >= 0); 393 | 394 | m_error = error; 395 | m_errorPos = pos; 396 | 397 | if (!m_partialStack.isEmpty()) 398 | { 399 | m_errorPartial = m_partialStack.top(); 400 | } 401 | } 402 | 403 | Tag Renderer::findTag(const QString& content, int pos, int endPos) 404 | { 405 | int tagStartPos = content.indexOf(m_tagStartMarker, pos); 406 | if (tagStartPos == -1 || tagStartPos >= endPos) { 407 | return Tag(); 408 | } 409 | 410 | int tagEndPos = content.indexOf(m_tagEndMarker, tagStartPos + m_tagStartMarker.length()); 411 | if (tagEndPos == -1) { 412 | return Tag(); 413 | } 414 | tagEndPos += m_tagEndMarker.length(); 415 | 416 | Tag tag; 417 | tag.type = Tag::Value; 418 | tag.start = tagStartPos; 419 | tag.end = tagEndPos; 420 | 421 | pos = tagStartPos + m_tagStartMarker.length(); 422 | endPos = tagEndPos - m_tagEndMarker.length(); 423 | 424 | QChar typeChar = content.at(pos); 425 | 426 | if (typeChar == '#') { 427 | tag.type = Tag::SectionStart; 428 | tag.key = readTagName(content, pos+1, endPos); 429 | } else if (typeChar == '^') { 430 | tag.type = Tag::InvertedSectionStart; 431 | tag.key = readTagName(content, pos+1, endPos); 432 | } else if (typeChar == '/') { 433 | tag.type = Tag::SectionEnd; 434 | tag.key = readTagName(content, pos+1, endPos); 435 | } else if (typeChar == '!') { 436 | tag.type = Tag::Comment; 437 | } else if (typeChar == '>') { 438 | tag.type = Tag::Partial; 439 | tag.key = readTagName(content, pos+1, endPos); 440 | } else if (typeChar == '=') { 441 | tag.type = Tag::SetDelimiter; 442 | readSetDelimiter(content, pos+1, tagEndPos - m_tagEndMarker.length()); 443 | } else { 444 | if (typeChar == '&') { 445 | tag.escapeMode = Tag::Unescape; 446 | ++pos; 447 | } else if (typeChar == '{') { 448 | tag.escapeMode = Tag::Raw; 449 | ++pos; 450 | int endTache = content.indexOf('}', pos); 451 | if (endTache == tag.end - m_tagEndMarker.length()) { 452 | ++tag.end; 453 | } else { 454 | endPos = endTache; 455 | } 456 | } 457 | tag.type = Tag::Value; 458 | tag.key = readTagName(content, pos, endPos); 459 | } 460 | 461 | if (tag.type != Tag::Value) { 462 | expandTag(tag, content); 463 | } 464 | 465 | return tag; 466 | } 467 | 468 | QString Renderer::readTagName(const QString& content, int pos, int endPos) 469 | { 470 | QString name; 471 | name.reserve(endPos - pos); 472 | while (content.at(pos).isSpace()) { 473 | ++pos; 474 | } 475 | while (!content.at(pos).isSpace() && pos < endPos) { 476 | name += content.at(pos); 477 | ++pos; 478 | } 479 | return name; 480 | } 481 | 482 | void Renderer::readSetDelimiter(const QString& content, int pos, int endPos) 483 | { 484 | QString startMarker; 485 | QString endMarker; 486 | 487 | while (content.at(pos).isSpace() && pos < endPos) { 488 | ++pos; 489 | } 490 | 491 | while (!content.at(pos).isSpace() && pos < endPos) { 492 | if (content.at(pos) == '=') { 493 | setError("Custom delimiters may not contain '='.", pos); 494 | return; 495 | } 496 | startMarker += content.at(pos); 497 | ++pos; 498 | } 499 | 500 | while (content.at(pos).isSpace() && pos < endPos) { 501 | ++pos; 502 | } 503 | 504 | while (!content.at(pos).isSpace() && pos < endPos - 1) { 505 | if (content.at(pos) == '=') { 506 | setError("Custom delimiters may not contain '='.", pos); 507 | return; 508 | } 509 | endMarker += content.at(pos); 510 | ++pos; 511 | } 512 | 513 | m_tagStartMarker = startMarker; 514 | m_tagEndMarker = endMarker; 515 | } 516 | 517 | Tag Renderer::findEndTag(const QString& content, const Tag& startTag, int endPos) 518 | { 519 | int tagDepth = 1; 520 | int pos = startTag.end; 521 | 522 | while (true) { 523 | Tag nextTag = findTag(content, pos, endPos); 524 | if (nextTag.type == Tag::Null) { 525 | return nextTag; 526 | } else if (nextTag.type == Tag::SectionStart || nextTag.type == Tag::InvertedSectionStart) { 527 | ++tagDepth; 528 | } else if (nextTag.type == Tag::SectionEnd) { 529 | --tagDepth; 530 | if (tagDepth == 0) { 531 | if (nextTag.key != startTag.key) { 532 | setError("Tag start/end key mismatch", nextTag.start); 533 | return Tag(); 534 | } 535 | return nextTag; 536 | } 537 | } 538 | pos = nextTag.end; 539 | } 540 | 541 | return Tag(); 542 | } 543 | 544 | void Renderer::setTagMarkers(const QString& startMarker, const QString& endMarker) 545 | { 546 | m_defaultTagStartMarker = startMarker; 547 | m_defaultTagEndMarker = endMarker; 548 | } 549 | 550 | void Renderer::expandTag(Tag& tag, const QString& content) 551 | { 552 | int start = tag.start; 553 | int end = tag.end; 554 | int indentation = 0; 555 | 556 | // Move start to beginning of line. 557 | while (start > 0 && content.at(start - 1) != QLatin1Char('\n')) { 558 | --start; 559 | if (!content.at(start).isSpace()) { 560 | return; // Not standalone. 561 | } else if (content.at(start).category() == QChar::Separator_Space) { 562 | // If its an actual "white space" and not a new line, it counts toward indentation. 563 | ++indentation; 564 | } 565 | } 566 | 567 | // Move end to one past end of line. 568 | while (end <= content.size() && content.at(end - 1) != QLatin1Char('\n')) { 569 | if (end < content.size() && !content.at(end).isSpace()) { 570 | return; // Not standalone. 571 | } 572 | ++end; 573 | } 574 | 575 | tag.start = start; 576 | tag.end = end; 577 | tag.indentation = indentation; 578 | } 579 | -------------------------------------------------------------------------------- /src/mustache.h: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2012, Robert Knight 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | 10 | Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | */ 14 | 15 | #pragma once 16 | 17 | #include 18 | #include 19 | #include 20 | 21 | #if __cplusplus >= 201103L 22 | #include /* for std::function */ 23 | #endif 24 | 25 | namespace Mustache 26 | { 27 | 28 | class PartialResolver; 29 | class Renderer; 30 | 31 | /** Context is an interface that Mustache::Renderer::render() uses to 32 | * fetch substitutions for template tags. 33 | */ 34 | class Context 35 | { 36 | public: 37 | /** Create a context. @p resolver is used to fetch the expansions for any {{>partial}} tags 38 | * which appear in a template. 39 | */ 40 | explicit Context(PartialResolver* resolver = 0); 41 | virtual ~Context() {} 42 | 43 | /** Returns a string representation of the value for @p key in the current context. 44 | * This is used to replace a Mustache value tag. 45 | */ 46 | virtual QString stringValue(const QString& key) const = 0; 47 | 48 | /** Returns true if the value for @p key is 'false' or an empty list. 49 | * 'False' values typically include empty strings, the boolean value false etc. 50 | * 51 | * When processing a section Mustache tag, the section is not rendered if the key 52 | * is false, or for an inverted section tag, the section is only rendered if the key 53 | * is false. 54 | */ 55 | virtual bool isFalse(const QString& key) const = 0; 56 | 57 | /** Returns the number of items in the list value for @p key or 0 if 58 | * the value for @p key is not a list. 59 | */ 60 | virtual int listCount(const QString& key) const = 0; 61 | 62 | /** Set the current context to the value for @p key. 63 | * If index is >= 0, set the current context to the @p index'th value 64 | * in the list value for @p key. 65 | */ 66 | virtual void push(const QString& key, int index = -1) = 0; 67 | 68 | /** Exit the current context. */ 69 | virtual void pop() = 0; 70 | 71 | /** Returns the partial template for a given @p key. */ 72 | QString partialValue(const QString& key) const; 73 | 74 | /** Returns the partial resolver passed to the constructor. */ 75 | PartialResolver* partialResolver() const; 76 | 77 | /** Returns true if eval() should be used to render section tags using @p key. 78 | * If canEval() returns true for a key, the renderer will pass the literal, unrendered 79 | * block of text for the section to eval() and replace the section with the result. 80 | * 81 | * canEval() and eval() are equivalents for callable objects (eg. lambdas) in other 82 | * Mustache implementations. 83 | * 84 | * The default implementation always returns false. 85 | */ 86 | virtual bool canEval(const QString& key) const; 87 | 88 | /** Callback used to render a template section with the given @p key. 89 | * @p renderer will substitute the original section tag with the result of eval(). 90 | * 91 | * The default implementation returns an empty string. 92 | */ 93 | virtual QString eval(const QString& key, const QString& _template, Renderer* renderer); 94 | 95 | private: 96 | PartialResolver* m_partialResolver; 97 | }; 98 | 99 | /** A context implementation which wraps a QVariantHash or QVariantMap. */ 100 | class QtVariantContext : public Context 101 | { 102 | public: 103 | /** Construct a QtVariantContext which wraps a dictionary in a QVariantHash 104 | * or a QVariantMap. 105 | */ 106 | #if __cplusplus >= 201103L 107 | typedef std::function fn_t; 108 | #else 109 | typedef QString (*fn_t)(const QString&, Mustache::Renderer*, Mustache::Context*); 110 | #endif 111 | explicit QtVariantContext(const QVariant& root, PartialResolver* resolver = 0); 112 | 113 | virtual QString stringValue(const QString& key) const; 114 | virtual bool isFalse(const QString& key) const; 115 | virtual int listCount(const QString& key) const; 116 | virtual void push(const QString& key, int index = -1); 117 | virtual void pop(); 118 | virtual bool canEval(const QString& key) const; 119 | virtual QString eval(const QString& key, const QString& _template, Mustache::Renderer* renderer); 120 | 121 | private: 122 | QVariant value(const QString& key) const; 123 | 124 | QStack m_contextStack; 125 | }; 126 | 127 | /** Interface for fetching template partials. */ 128 | class PartialResolver 129 | { 130 | public: 131 | virtual ~PartialResolver() {} 132 | 133 | /** Returns the partial template with a given @p name. */ 134 | virtual QString getPartial(const QString& name) = 0; 135 | }; 136 | 137 | /** A simple partial fetcher which returns templates from a map of (partial name -> template) 138 | */ 139 | class PartialMap : public PartialResolver 140 | { 141 | public: 142 | explicit PartialMap(const QHash& partials); 143 | 144 | virtual QString getPartial(const QString& name); 145 | 146 | private: 147 | QHash m_partials; 148 | }; 149 | 150 | /** A partial fetcher when loads templates from '.mustache' files 151 | * in a given directory. 152 | * 153 | * Once a partial has been loaded, it is cached for future use. 154 | */ 155 | class PartialFileLoader : public PartialResolver 156 | { 157 | public: 158 | explicit PartialFileLoader(const QString& basePath); 159 | 160 | virtual QString getPartial(const QString& name); 161 | 162 | private: 163 | QString m_basePath; 164 | QHash m_cache; 165 | }; 166 | 167 | /** Holds properties of a tag in a mustache template. */ 168 | struct Tag 169 | { 170 | enum Type 171 | { 172 | Null, 173 | Value, /// A {{key}} or {{{key}}} tag 174 | SectionStart, /// A {{#section}} tag 175 | InvertedSectionStart, /// An {{^inverted-section}} tag 176 | SectionEnd, /// A {{/section}} tag 177 | Partial, /// A {{^partial}} tag 178 | Comment, /// A {{! comment }} tag 179 | SetDelimiter /// A {{=<% %>=}} tag 180 | }; 181 | 182 | enum EscapeMode 183 | { 184 | Escape, 185 | Unescape, 186 | Raw 187 | }; 188 | 189 | Tag() 190 | : type(Null) 191 | , start(0) 192 | , end(0) 193 | , escapeMode(Escape) 194 | , indentation(0) 195 | {} 196 | 197 | Type type; 198 | QString key; 199 | int start; 200 | int end; 201 | EscapeMode escapeMode; 202 | int indentation; 203 | }; 204 | 205 | /** Renders Mustache templates, replacing mustache tags with 206 | * values from a provided context. 207 | */ 208 | class Renderer 209 | { 210 | public: 211 | Renderer(); 212 | 213 | /** Render a Mustache template, using @p context to fetch 214 | * the values used to replace Mustache tags. 215 | */ 216 | QString render(const QString& _template, Context* context); 217 | 218 | /** Returns a message describing the last error encountered by the previous 219 | * render() call. 220 | */ 221 | QString error() const; 222 | 223 | /** Returns the position in the template where the last error occurred 224 | * when rendering the template or -1 if no error occurred. 225 | * 226 | * If the error occurred in a partial template, the returned position is the offset 227 | * in the partial template. 228 | */ 229 | int errorPos() const; 230 | 231 | /** Returns the name of the partial where the error occurred, or an empty string 232 | * if the error occurred in the main template. 233 | */ 234 | QString errorPartial() const; 235 | 236 | /** Sets the default tag start and end markers. 237 | * This can be overridden within a template. 238 | */ 239 | void setTagMarkers(const QString& startMarker, const QString& endMarker); 240 | 241 | private: 242 | QString render(const QString& _template, int startPos, int endPos, Context* context); 243 | 244 | Tag findTag(const QString& content, int pos, int endPos); 245 | Tag findEndTag(const QString& content, const Tag& startTag, int endPos); 246 | void setError(const QString& error, int pos); 247 | 248 | void readSetDelimiter(const QString& content, int pos, int endPos); 249 | static QString readTagName(const QString& content, int pos, int endPos); 250 | 251 | /** Expands @p tag to fill the line, but only if it is standalone. 252 | * 253 | * The start position is moved to the beginning of the line. The end position is 254 | * moved to one past the end of the line. If @p tag is not standalone, it is 255 | * left unmodified. 256 | * 257 | * A tag is standalone if it is the only non-whitespace token on the the line. 258 | */ 259 | static void expandTag(Tag& tag, const QString& content); 260 | 261 | QStack m_partialStack; 262 | QString m_error; 263 | int m_errorPos; 264 | QString m_errorPartial; 265 | 266 | QString m_tagStartMarker; 267 | QString m_tagEndMarker; 268 | 269 | QString m_defaultTagStartMarker; 270 | QString m_defaultTagEndMarker; 271 | }; 272 | 273 | /** A convenience function which renders a template using the given data. */ 274 | QString renderTemplate(const QString& templateString, const QVariantHash& args); 275 | 276 | } 277 | 278 | Q_DECLARE_METATYPE(Mustache::QtVariantContext::fn_t) 279 | -------------------------------------------------------------------------------- /tests/partial.mustache: -------------------------------------------------------------------------------- 1 | {{name}} -- {{email}} 2 | -------------------------------------------------------------------------------- /tests/specs/comments.json: -------------------------------------------------------------------------------- 1 | {"__ATTN__":"Do not edit this file; changes belong in the appropriate YAML file.","overview":"Comment tags represent content that should never appear in the resulting\noutput.\n\nThe tag's content may contain any substring (including newlines) EXCEPT the\nclosing delimiter.\n\nComment tags SHOULD be treated as standalone when appropriate.\n","tests":[{"name":"Inline","data":{},"expected":"1234567890","template":"12345{{! Comment Block! }}67890","desc":"Comment blocks should be removed from the template."},{"name":"Multiline","data":{},"expected":"1234567890\n","template":"12345{{!\n This is a\n multi-line comment...\n}}67890\n","desc":"Multiline comments should be permitted."},{"name":"Standalone","data":{},"expected":"Begin.\nEnd.\n","template":"Begin.\n{{! Comment Block! }}\nEnd.\n","desc":"All standalone comment lines should be removed."},{"name":"Indented Standalone","data":{},"expected":"Begin.\nEnd.\n","template":"Begin.\n {{! Indented Comment Block! }}\nEnd.\n","desc":"All standalone comment lines should be removed."},{"name":"Standalone Line Endings","data":{},"expected":"|\r\n|","template":"|\r\n{{! Standalone Comment }}\r\n|","desc":"\"\\r\\n\" should be considered a newline for standalone tags."},{"name":"Standalone Without Previous Line","data":{},"expected":"!","template":" {{! I'm Still Standalone }}\n!","desc":"Standalone tags should not require a newline to precede them."},{"name":"Standalone Without Newline","data":{},"expected":"!\n","template":"!\n {{! I'm Still Standalone }}","desc":"Standalone tags should not require a newline to follow them."},{"name":"Multiline Standalone","data":{},"expected":"Begin.\nEnd.\n","template":"Begin.\n{{!\nSomething's going on here...\n}}\nEnd.\n","desc":"All standalone comment lines should be removed."},{"name":"Indented Multiline Standalone","data":{},"expected":"Begin.\nEnd.\n","template":"Begin.\n {{!\n Something's going on here...\n }}\nEnd.\n","desc":"All standalone comment lines should be removed."},{"name":"Indented Inline","data":{},"expected":" 12 \n","template":" 12 {{! 34 }}\n","desc":"Inline comments should not strip whitespace"},{"name":"Surrounding Whitespace","data":{},"expected":"12345 67890","template":"12345 {{! Comment Block! }} 67890","desc":"Comment removal should preserve surrounding whitespace."}]} -------------------------------------------------------------------------------- /tests/specs/comments.yml: -------------------------------------------------------------------------------- 1 | overview: | 2 | Comment tags represent content that should never appear in the resulting 3 | output. 4 | 5 | The tag's content may contain any substring (including newlines) EXCEPT the 6 | closing delimiter. 7 | 8 | Comment tags SHOULD be treated as standalone when appropriate. 9 | tests: 10 | - name: Inline 11 | desc: Comment blocks should be removed from the template. 12 | data: { } 13 | template: '12345{{! Comment Block! }}67890' 14 | expected: '1234567890' 15 | 16 | - name: Multiline 17 | desc: Multiline comments should be permitted. 18 | data: { } 19 | template: | 20 | 12345{{! 21 | This is a 22 | multi-line comment... 23 | }}67890 24 | expected: | 25 | 1234567890 26 | 27 | - name: Standalone 28 | desc: All standalone comment lines should be removed. 29 | data: { } 30 | template: | 31 | Begin. 32 | {{! Comment Block! }} 33 | End. 34 | expected: | 35 | Begin. 36 | End. 37 | 38 | - name: Indented Standalone 39 | desc: All standalone comment lines should be removed. 40 | data: { } 41 | template: | 42 | Begin. 43 | {{! Indented Comment Block! }} 44 | End. 45 | expected: | 46 | Begin. 47 | End. 48 | 49 | - name: Standalone Line Endings 50 | desc: '"\r\n" should be considered a newline for standalone tags.' 51 | data: { } 52 | template: "|\r\n{{! Standalone Comment }}\r\n|" 53 | expected: "|\r\n|" 54 | 55 | - name: Standalone Without Previous Line 56 | desc: Standalone tags should not require a newline to precede them. 57 | data: { } 58 | template: " {{! I'm Still Standalone }}\n!" 59 | expected: "!" 60 | 61 | - name: Standalone Without Newline 62 | desc: Standalone tags should not require a newline to follow them. 63 | data: { } 64 | template: "!\n {{! I'm Still Standalone }}" 65 | expected: "!\n" 66 | 67 | - name: Multiline Standalone 68 | desc: All standalone comment lines should be removed. 69 | data: { } 70 | template: | 71 | Begin. 72 | {{! 73 | Something's going on here... 74 | }} 75 | End. 76 | expected: | 77 | Begin. 78 | End. 79 | 80 | - name: Indented Multiline Standalone 81 | desc: All standalone comment lines should be removed. 82 | data: { } 83 | template: | 84 | Begin. 85 | {{! 86 | Something's going on here... 87 | }} 88 | End. 89 | expected: | 90 | Begin. 91 | End. 92 | 93 | - name: Indented Inline 94 | desc: Inline comments should not strip whitespace 95 | data: { } 96 | template: " 12 {{! 34 }}\n" 97 | expected: " 12 \n" 98 | 99 | - name: Surrounding Whitespace 100 | desc: Comment removal should preserve surrounding whitespace. 101 | data: { } 102 | template: '12345 {{! Comment Block! }} 67890' 103 | expected: '12345 67890' 104 | -------------------------------------------------------------------------------- /tests/specs/delimiters.json: -------------------------------------------------------------------------------- 1 | {"__ATTN__":"Do not edit this file; changes belong in the appropriate YAML file.","overview":"Set Delimiter tags are used to change the tag delimiters for all content\nfollowing the tag in the current compilation unit.\n\nThe tag's content MUST be any two non-whitespace sequences (separated by\nwhitespace) EXCEPT an equals sign ('=') followed by the current closing\ndelimiter.\n\nSet Delimiter tags SHOULD be treated as standalone when appropriate.\n","tests":[{"name":"Pair Behavior","data":{"text":"Hey!"},"expected":"(Hey!)","template":"{{=<% %>=}}(<%text%>)","desc":"The equals sign (used on both sides) should permit delimiter changes."},{"name":"Special Characters","data":{"text":"It worked!"},"expected":"(It worked!)","template":"({{=[ ]=}}[text])","desc":"Characters with special meaning regexen should be valid delimiters."},{"name":"Sections","data":{"section":true,"data":"I got interpolated."},"expected":"[\n I got interpolated.\n |data|\n\n {{data}}\n I got interpolated.\n]\n","template":"[\n{{#section}}\n {{data}}\n |data|\n{{/section}}\n\n{{= | | =}}\n|#section|\n {{data}}\n |data|\n|/section|\n]\n","desc":"Delimiters set outside sections should persist."},{"name":"Inverted Sections","data":{"section":false,"data":"I got interpolated."},"expected":"[\n I got interpolated.\n |data|\n\n {{data}}\n I got interpolated.\n]\n","template":"[\n{{^section}}\n {{data}}\n |data|\n{{/section}}\n\n{{= | | =}}\n|^section|\n {{data}}\n |data|\n|/section|\n]\n","desc":"Delimiters set outside inverted sections should persist."},{"name":"Partial Inheritence","data":{"value":"yes"},"expected":"[ .yes. ]\n[ .yes. ]\n","template":"[ {{>include}} ]\n{{= | | =}}\n[ |>include| ]\n","desc":"Delimiters set in a parent template should not affect a partial.","partials":{"include":".{{value}}."}},{"name":"Post-Partial Behavior","data":{"value":"yes"},"expected":"[ .yes. .yes. ]\n[ .yes. .|value|. ]\n","template":"[ {{>include}} ]\n[ .{{value}}. .|value|. ]\n","desc":"Delimiters set in a partial should not affect the parent template.","partials":{"include":".{{value}}. {{= | | =}} .|value|."}},{"name":"Surrounding Whitespace","data":{},"expected":"| |","template":"| {{=@ @=}} |","desc":"Surrounding whitespace should be left untouched."},{"name":"Outlying Whitespace (Inline)","data":{},"expected":" | \n","template":" | {{=@ @=}}\n","desc":"Whitespace should be left untouched."},{"name":"Standalone Tag","data":{},"expected":"Begin.\nEnd.\n","template":"Begin.\n{{=@ @=}}\nEnd.\n","desc":"Standalone lines should be removed from the template."},{"name":"Indented Standalone Tag","data":{},"expected":"Begin.\nEnd.\n","template":"Begin.\n {{=@ @=}}\nEnd.\n","desc":"Indented standalone lines should be removed from the template."},{"name":"Standalone Line Endings","data":{},"expected":"|\r\n|","template":"|\r\n{{= @ @ =}}\r\n|","desc":"\"\\r\\n\" should be considered a newline for standalone tags."},{"name":"Standalone Without Previous Line","data":{},"expected":"=","template":" {{=@ @=}}\n=","desc":"Standalone tags should not require a newline to precede them."},{"name":"Standalone Without Newline","data":{},"expected":"=\n","template":"=\n {{=@ @=}}","desc":"Standalone tags should not require a newline to follow them."},{"name":"Pair with Padding","data":{},"expected":"||","template":"|{{= @ @ =}}|","desc":"Superfluous in-tag whitespace should be ignored."}]} -------------------------------------------------------------------------------- /tests/specs/delimiters.yml: -------------------------------------------------------------------------------- 1 | overview: | 2 | Set Delimiter tags are used to change the tag delimiters for all content 3 | following the tag in the current compilation unit. 4 | 5 | The tag's content MUST be any two non-whitespace sequences (separated by 6 | whitespace) EXCEPT an equals sign ('=') followed by the current closing 7 | delimiter. 8 | 9 | Set Delimiter tags SHOULD be treated as standalone when appropriate. 10 | tests: 11 | - name: Pair Behavior 12 | desc: The equals sign (used on both sides) should permit delimiter changes. 13 | data: { text: 'Hey!' } 14 | template: '{{=<% %>=}}(<%text%>)' 15 | expected: '(Hey!)' 16 | 17 | - name: Special Characters 18 | desc: Characters with special meaning regexen should be valid delimiters. 19 | data: { text: 'It worked!' } 20 | template: '({{=[ ]=}}[text])' 21 | expected: '(It worked!)' 22 | 23 | - name: Sections 24 | desc: Delimiters set outside sections should persist. 25 | data: { section: true, data: 'I got interpolated.' } 26 | template: | 27 | [ 28 | {{#section}} 29 | {{data}} 30 | |data| 31 | {{/section}} 32 | 33 | {{= | | =}} 34 | |#section| 35 | {{data}} 36 | |data| 37 | |/section| 38 | ] 39 | expected: | 40 | [ 41 | I got interpolated. 42 | |data| 43 | 44 | {{data}} 45 | I got interpolated. 46 | ] 47 | 48 | - name: Inverted Sections 49 | desc: Delimiters set outside inverted sections should persist. 50 | data: { section: false, data: 'I got interpolated.' } 51 | template: | 52 | [ 53 | {{^section}} 54 | {{data}} 55 | |data| 56 | {{/section}} 57 | 58 | {{= | | =}} 59 | |^section| 60 | {{data}} 61 | |data| 62 | |/section| 63 | ] 64 | expected: | 65 | [ 66 | I got interpolated. 67 | |data| 68 | 69 | {{data}} 70 | I got interpolated. 71 | ] 72 | 73 | - name: Partial Inheritence 74 | desc: Delimiters set in a parent template should not affect a partial. 75 | data: { value: 'yes' } 76 | partials: 77 | include: '.{{value}}.' 78 | template: | 79 | [ {{>include}} ] 80 | {{= | | =}} 81 | [ |>include| ] 82 | expected: | 83 | [ .yes. ] 84 | [ .yes. ] 85 | 86 | - name: Post-Partial Behavior 87 | desc: Delimiters set in a partial should not affect the parent template. 88 | data: { value: 'yes' } 89 | partials: 90 | include: '.{{value}}. {{= | | =}} .|value|.' 91 | template: | 92 | [ {{>include}} ] 93 | [ .{{value}}. .|value|. ] 94 | expected: | 95 | [ .yes. .yes. ] 96 | [ .yes. .|value|. ] 97 | 98 | # Whitespace Sensitivity 99 | 100 | - name: Surrounding Whitespace 101 | desc: Surrounding whitespace should be left untouched. 102 | data: { } 103 | template: '| {{=@ @=}} |' 104 | expected: '| |' 105 | 106 | - name: Outlying Whitespace (Inline) 107 | desc: Whitespace should be left untouched. 108 | data: { } 109 | template: " | {{=@ @=}}\n" 110 | expected: " | \n" 111 | 112 | - name: Standalone Tag 113 | desc: Standalone lines should be removed from the template. 114 | data: { } 115 | template: | 116 | Begin. 117 | {{=@ @=}} 118 | End. 119 | expected: | 120 | Begin. 121 | End. 122 | 123 | - name: Indented Standalone Tag 124 | desc: Indented standalone lines should be removed from the template. 125 | data: { } 126 | template: | 127 | Begin. 128 | {{=@ @=}} 129 | End. 130 | expected: | 131 | Begin. 132 | End. 133 | 134 | - name: Standalone Line Endings 135 | desc: '"\r\n" should be considered a newline for standalone tags.' 136 | data: { } 137 | template: "|\r\n{{= @ @ =}}\r\n|" 138 | expected: "|\r\n|" 139 | 140 | - name: Standalone Without Previous Line 141 | desc: Standalone tags should not require a newline to precede them. 142 | data: { } 143 | template: " {{=@ @=}}\n=" 144 | expected: "=" 145 | 146 | - name: Standalone Without Newline 147 | desc: Standalone tags should not require a newline to follow them. 148 | data: { } 149 | template: "=\n {{=@ @=}}" 150 | expected: "=\n" 151 | 152 | # Whitespace Insensitivity 153 | 154 | - name: Pair with Padding 155 | desc: Superfluous in-tag whitespace should be ignored. 156 | data: { } 157 | template: '|{{= @ @ =}}|' 158 | expected: '||' 159 | -------------------------------------------------------------------------------- /tests/specs/interpolation.json: -------------------------------------------------------------------------------- 1 | {"__ATTN__":"Do not edit this file; changes belong in the appropriate YAML file.","overview":"Interpolation tags are used to integrate dynamic content into the template.\n\nThe tag's content MUST be a non-whitespace character sequence NOT containing\nthe current closing delimiter.\n\nThis tag's content names the data to replace the tag. A single period (`.`)\nindicates that the item currently sitting atop the context stack should be\nused; otherwise, name resolution is as follows:\n 1) Split the name on periods; the first part is the name to resolve, any\n remaining parts should be retained.\n 2) Walk the context stack from top to bottom, finding the first context\n that is a) a hash containing the name as a key OR b) an object responding\n to a method with the given name.\n 3) If the context is a hash, the data is the value associated with the\n name.\n 4) If the context is an object, the data is the value returned by the\n method with the given name.\n 5) If any name parts were retained in step 1, each should be resolved\n against a context stack containing only the result from the former\n resolution. If any part fails resolution, the result should be considered\n falsey, and should interpolate as the empty string.\nData should be coerced into a string (and escaped, if appropriate) before\ninterpolation.\n\nThe Interpolation tags MUST NOT be treated as standalone.\n","tests":[{"name":"No Interpolation","data":{},"expected":"Hello from {Mustache}!\n","template":"Hello from {Mustache}!\n","desc":"Mustache-free templates should render as-is."},{"name":"Basic Interpolation","data":{"subject":"world"},"expected":"Hello, world!\n","template":"Hello, {{subject}}!\n","desc":"Unadorned tags should interpolate content into the template."},{"name":"HTML Escaping","data":{"forbidden":"& \" < >"},"expected":"These characters should be HTML escaped: & " < >\n","template":"These characters should be HTML escaped: {{forbidden}}\n","desc":"Basic interpolation should be HTML escaped."},{"name":"Triple Mustache","data":{"forbidden":"& \" < >"},"expected":"These characters should not be HTML escaped: & \" < >\n","template":"These characters should not be HTML escaped: {{{forbidden}}}\n","desc":"Triple mustaches should interpolate without HTML escaping."},{"name":"Ampersand","data":{"forbidden":"& \" < >"},"expected":"These characters should not be HTML escaped: & \" < >\n","template":"These characters should not be HTML escaped: {{&forbidden}}\n","desc":"Ampersand should interpolate without HTML escaping."},{"name":"Basic Integer Interpolation","data":{"mph":85},"expected":"\"85 miles an hour!\"","template":"\"{{mph}} miles an hour!\"","desc":"Integers should interpolate seamlessly."},{"name":"Triple Mustache Integer Interpolation","data":{"mph":85},"expected":"\"85 miles an hour!\"","template":"\"{{{mph}}} miles an hour!\"","desc":"Integers should interpolate seamlessly."},{"name":"Ampersand Integer Interpolation","data":{"mph":85},"expected":"\"85 miles an hour!\"","template":"\"{{&mph}} miles an hour!\"","desc":"Integers should interpolate seamlessly."},{"name":"Basic Decimal Interpolation","data":{"power":1.21},"expected":"\"1.21 jiggawatts!\"","template":"\"{{power}} jiggawatts!\"","desc":"Decimals should interpolate seamlessly with proper significance."},{"name":"Triple Mustache Decimal Interpolation","data":{"power":1.21},"expected":"\"1.21 jiggawatts!\"","template":"\"{{{power}}} jiggawatts!\"","desc":"Decimals should interpolate seamlessly with proper significance."},{"name":"Ampersand Decimal Interpolation","data":{"power":1.21},"expected":"\"1.21 jiggawatts!\"","template":"\"{{&power}} jiggawatts!\"","desc":"Decimals should interpolate seamlessly with proper significance."},{"name":"Basic Context Miss Interpolation","data":{},"expected":"I () be seen!","template":"I ({{cannot}}) be seen!","desc":"Failed context lookups should default to empty strings."},{"name":"Triple Mustache Context Miss Interpolation","data":{},"expected":"I () be seen!","template":"I ({{{cannot}}}) be seen!","desc":"Failed context lookups should default to empty strings."},{"name":"Ampersand Context Miss Interpolation","data":{},"expected":"I () be seen!","template":"I ({{&cannot}}) be seen!","desc":"Failed context lookups should default to empty strings."},{"name":"Dotted Names - Basic Interpolation","data":{"person":{"name":"Joe"}},"expected":"\"Joe\" == \"Joe\"","template":"\"{{person.name}}\" == \"{{#person}}{{name}}{{/person}}\"","desc":"Dotted names should be considered a form of shorthand for sections."},{"name":"Dotted Names - Triple Mustache Interpolation","data":{"person":{"name":"Joe"}},"expected":"\"Joe\" == \"Joe\"","template":"\"{{{person.name}}}\" == \"{{#person}}{{{name}}}{{/person}}\"","desc":"Dotted names should be considered a form of shorthand for sections."},{"name":"Dotted Names - Ampersand Interpolation","data":{"person":{"name":"Joe"}},"expected":"\"Joe\" == \"Joe\"","template":"\"{{&person.name}}\" == \"{{#person}}{{&name}}{{/person}}\"","desc":"Dotted names should be considered a form of shorthand for sections."},{"name":"Dotted Names - Arbitrary Depth","data":{"a":{"b":{"c":{"d":{"e":{"name":"Phil"}}}}}},"expected":"\"Phil\" == \"Phil\"","template":"\"{{a.b.c.d.e.name}}\" == \"Phil\"","desc":"Dotted names should be functional to any level of nesting."},{"name":"Dotted Names - Broken Chains","data":{"a":{}},"expected":"\"\" == \"\"","template":"\"{{a.b.c}}\" == \"\"","desc":"Any falsey value prior to the last part of the name should yield ''."},{"name":"Dotted Names - Broken Chain Resolution","data":{"a":{"b":{}},"c":{"name":"Jim"}},"expected":"\"\" == \"\"","template":"\"{{a.b.c.name}}\" == \"\"","desc":"Each part of a dotted name should resolve only against its parent."},{"name":"Dotted Names - Initial Resolution","data":{"a":{"b":{"c":{"d":{"e":{"name":"Phil"}}}}},"b":{"c":{"d":{"e":{"name":"Wrong"}}}}},"expected":"\"Phil\" == \"Phil\"","template":"\"{{#a}}{{b.c.d.e.name}}{{/a}}\" == \"Phil\"","desc":"The first part of a dotted name should resolve as any other name."},{"name":"Interpolation - Surrounding Whitespace","data":{"string":"---"},"expected":"| --- |","template":"| {{string}} |","desc":"Interpolation should not alter surrounding whitespace."},{"name":"Triple Mustache - Surrounding Whitespace","data":{"string":"---"},"expected":"| --- |","template":"| {{{string}}} |","desc":"Interpolation should not alter surrounding whitespace."},{"name":"Ampersand - Surrounding Whitespace","data":{"string":"---"},"expected":"| --- |","template":"| {{&string}} |","desc":"Interpolation should not alter surrounding whitespace."},{"name":"Interpolation - Standalone","data":{"string":"---"},"expected":" ---\n","template":" {{string}}\n","desc":"Standalone interpolation should not alter surrounding whitespace."},{"name":"Triple Mustache - Standalone","data":{"string":"---"},"expected":" ---\n","template":" {{{string}}}\n","desc":"Standalone interpolation should not alter surrounding whitespace."},{"name":"Ampersand - Standalone","data":{"string":"---"},"expected":" ---\n","template":" {{&string}}\n","desc":"Standalone interpolation should not alter surrounding whitespace."},{"name":"Interpolation With Padding","data":{"string":"---"},"expected":"|---|","template":"|{{ string }}|","desc":"Superfluous in-tag whitespace should be ignored."},{"name":"Triple Mustache With Padding","data":{"string":"---"},"expected":"|---|","template":"|{{{ string }}}|","desc":"Superfluous in-tag whitespace should be ignored."},{"name":"Ampersand With Padding","data":{"string":"---"},"expected":"|---|","template":"|{{& string }}|","desc":"Superfluous in-tag whitespace should be ignored."}]} -------------------------------------------------------------------------------- /tests/specs/interpolation.yml: -------------------------------------------------------------------------------- 1 | overview: | 2 | Interpolation tags are used to integrate dynamic content into the template. 3 | 4 | The tag's content MUST be a non-whitespace character sequence NOT containing 5 | the current closing delimiter. 6 | 7 | This tag's content names the data to replace the tag. A single period (`.`) 8 | indicates that the item currently sitting atop the context stack should be 9 | used; otherwise, name resolution is as follows: 10 | 1) Split the name on periods; the first part is the name to resolve, any 11 | remaining parts should be retained. 12 | 2) Walk the context stack from top to bottom, finding the first context 13 | that is a) a hash containing the name as a key OR b) an object responding 14 | to a method with the given name. 15 | 3) If the context is a hash, the data is the value associated with the 16 | name. 17 | 4) If the context is an object, the data is the value returned by the 18 | method with the given name. 19 | 5) If any name parts were retained in step 1, each should be resolved 20 | against a context stack containing only the result from the former 21 | resolution. If any part fails resolution, the result should be considered 22 | falsey, and should interpolate as the empty string. 23 | Data should be coerced into a string (and escaped, if appropriate) before 24 | interpolation. 25 | 26 | The Interpolation tags MUST NOT be treated as standalone. 27 | tests: 28 | - name: No Interpolation 29 | desc: Mustache-free templates should render as-is. 30 | data: { } 31 | template: | 32 | Hello from {Mustache}! 33 | expected: | 34 | Hello from {Mustache}! 35 | 36 | - name: Basic Interpolation 37 | desc: Unadorned tags should interpolate content into the template. 38 | data: { subject: "world" } 39 | template: | 40 | Hello, {{subject}}! 41 | expected: | 42 | Hello, world! 43 | 44 | - name: HTML Escaping 45 | desc: Basic interpolation should be HTML escaped. 46 | data: { forbidden: '& " < >' } 47 | template: | 48 | These characters should be HTML escaped: {{forbidden}} 49 | expected: | 50 | These characters should be HTML escaped: & " < > 51 | 52 | - name: Triple Mustache 53 | desc: Triple mustaches should interpolate without HTML escaping. 54 | data: { forbidden: '& " < >' } 55 | template: | 56 | These characters should not be HTML escaped: {{{forbidden}}} 57 | expected: | 58 | These characters should not be HTML escaped: & " < > 59 | 60 | - name: Ampersand 61 | desc: Ampersand should interpolate without HTML escaping. 62 | data: { forbidden: '& " < >' } 63 | template: | 64 | These characters should not be HTML escaped: {{&forbidden}} 65 | expected: | 66 | These characters should not be HTML escaped: & " < > 67 | 68 | - name: Basic Integer Interpolation 69 | desc: Integers should interpolate seamlessly. 70 | data: { mph: 85 } 71 | template: '"{{mph}} miles an hour!"' 72 | expected: '"85 miles an hour!"' 73 | 74 | - name: Triple Mustache Integer Interpolation 75 | desc: Integers should interpolate seamlessly. 76 | data: { mph: 85 } 77 | template: '"{{{mph}}} miles an hour!"' 78 | expected: '"85 miles an hour!"' 79 | 80 | - name: Ampersand Integer Interpolation 81 | desc: Integers should interpolate seamlessly. 82 | data: { mph: 85 } 83 | template: '"{{&mph}} miles an hour!"' 84 | expected: '"85 miles an hour!"' 85 | 86 | - name: Basic Decimal Interpolation 87 | desc: Decimals should interpolate seamlessly with proper significance. 88 | data: { power: 1.210 } 89 | template: '"{{power}} jiggawatts!"' 90 | expected: '"1.21 jiggawatts!"' 91 | 92 | - name: Triple Mustache Decimal Interpolation 93 | desc: Decimals should interpolate seamlessly with proper significance. 94 | data: { power: 1.210 } 95 | template: '"{{{power}}} jiggawatts!"' 96 | expected: '"1.21 jiggawatts!"' 97 | 98 | - name: Ampersand Decimal Interpolation 99 | desc: Decimals should interpolate seamlessly with proper significance. 100 | data: { power: 1.210 } 101 | template: '"{{&power}} jiggawatts!"' 102 | expected: '"1.21 jiggawatts!"' 103 | 104 | # Context Misses 105 | 106 | - name: Basic Context Miss Interpolation 107 | desc: Failed context lookups should default to empty strings. 108 | data: { } 109 | template: "I ({{cannot}}) be seen!" 110 | expected: "I () be seen!" 111 | 112 | - name: Triple Mustache Context Miss Interpolation 113 | desc: Failed context lookups should default to empty strings. 114 | data: { } 115 | template: "I ({{{cannot}}}) be seen!" 116 | expected: "I () be seen!" 117 | 118 | - name: Ampersand Context Miss Interpolation 119 | desc: Failed context lookups should default to empty strings. 120 | data: { } 121 | template: "I ({{&cannot}}) be seen!" 122 | expected: "I () be seen!" 123 | 124 | # Dotted Names 125 | 126 | - name: Dotted Names - Basic Interpolation 127 | desc: Dotted names should be considered a form of shorthand for sections. 128 | data: { person: { name: 'Joe' } } 129 | template: '"{{person.name}}" == "{{#person}}{{name}}{{/person}}"' 130 | expected: '"Joe" == "Joe"' 131 | 132 | - name: Dotted Names - Triple Mustache Interpolation 133 | desc: Dotted names should be considered a form of shorthand for sections. 134 | data: { person: { name: 'Joe' } } 135 | template: '"{{{person.name}}}" == "{{#person}}{{{name}}}{{/person}}"' 136 | expected: '"Joe" == "Joe"' 137 | 138 | - name: Dotted Names - Ampersand Interpolation 139 | desc: Dotted names should be considered a form of shorthand for sections. 140 | data: { person: { name: 'Joe' } } 141 | template: '"{{&person.name}}" == "{{#person}}{{&name}}{{/person}}"' 142 | expected: '"Joe" == "Joe"' 143 | 144 | - name: Dotted Names - Arbitrary Depth 145 | desc: Dotted names should be functional to any level of nesting. 146 | data: 147 | a: { b: { c: { d: { e: { name: 'Phil' } } } } } 148 | template: '"{{a.b.c.d.e.name}}" == "Phil"' 149 | expected: '"Phil" == "Phil"' 150 | 151 | - name: Dotted Names - Broken Chains 152 | desc: Any falsey value prior to the last part of the name should yield ''. 153 | data: 154 | a: { } 155 | template: '"{{a.b.c}}" == ""' 156 | expected: '"" == ""' 157 | 158 | - name: Dotted Names - Broken Chain Resolution 159 | desc: Each part of a dotted name should resolve only against its parent. 160 | data: 161 | a: { b: { } } 162 | c: { name: 'Jim' } 163 | template: '"{{a.b.c.name}}" == ""' 164 | expected: '"" == ""' 165 | 166 | - name: Dotted Names - Initial Resolution 167 | desc: The first part of a dotted name should resolve as any other name. 168 | data: 169 | a: { b: { c: { d: { e: { name: 'Phil' } } } } } 170 | b: { c: { d: { e: { name: 'Wrong' } } } } 171 | template: '"{{#a}}{{b.c.d.e.name}}{{/a}}" == "Phil"' 172 | expected: '"Phil" == "Phil"' 173 | 174 | - name: Dotted Names - Context Precedence 175 | desc: Dotted names should be resolved against former resolutions. 176 | data: 177 | a: { b: { } } 178 | b: { c: 'ERROR' } 179 | template: '{{#a}}{{b.c}}{{/a}}' 180 | expected: '' 181 | 182 | # Whitespace Sensitivity 183 | 184 | - name: Interpolation - Surrounding Whitespace 185 | desc: Interpolation should not alter surrounding whitespace. 186 | data: { string: '---' } 187 | template: '| {{string}} |' 188 | expected: '| --- |' 189 | 190 | - name: Triple Mustache - Surrounding Whitespace 191 | desc: Interpolation should not alter surrounding whitespace. 192 | data: { string: '---' } 193 | template: '| {{{string}}} |' 194 | expected: '| --- |' 195 | 196 | - name: Ampersand - Surrounding Whitespace 197 | desc: Interpolation should not alter surrounding whitespace. 198 | data: { string: '---' } 199 | template: '| {{&string}} |' 200 | expected: '| --- |' 201 | 202 | - name: Interpolation - Standalone 203 | desc: Standalone interpolation should not alter surrounding whitespace. 204 | data: { string: '---' } 205 | template: " {{string}}\n" 206 | expected: " ---\n" 207 | 208 | - name: Triple Mustache - Standalone 209 | desc: Standalone interpolation should not alter surrounding whitespace. 210 | data: { string: '---' } 211 | template: " {{{string}}}\n" 212 | expected: " ---\n" 213 | 214 | - name: Ampersand - Standalone 215 | desc: Standalone interpolation should not alter surrounding whitespace. 216 | data: { string: '---' } 217 | template: " {{&string}}\n" 218 | expected: " ---\n" 219 | 220 | # Whitespace Insensitivity 221 | 222 | - name: Interpolation With Padding 223 | desc: Superfluous in-tag whitespace should be ignored. 224 | data: { string: "---" } 225 | template: '|{{ string }}|' 226 | expected: '|---|' 227 | 228 | - name: Triple Mustache With Padding 229 | desc: Superfluous in-tag whitespace should be ignored. 230 | data: { string: "---" } 231 | template: '|{{{ string }}}|' 232 | expected: '|---|' 233 | 234 | - name: Ampersand With Padding 235 | desc: Superfluous in-tag whitespace should be ignored. 236 | data: { string: "---" } 237 | template: '|{{& string }}|' 238 | expected: '|---|' 239 | -------------------------------------------------------------------------------- /tests/specs/inverted.json: -------------------------------------------------------------------------------- 1 | {"__ATTN__":"Do not edit this file; changes belong in the appropriate YAML file.","overview":"Inverted Section tags and End Section tags are used in combination to wrap a\nsection of the template.\n\nThese tags' content MUST be a non-whitespace character sequence NOT\ncontaining the current closing delimiter; each Inverted Section tag MUST be\nfollowed by an End Section tag with the same content within the same\nsection.\n\nThis tag's content names the data to replace the tag. Name resolution is as\nfollows:\n 1) Split the name on periods; the first part is the name to resolve, any\n remaining parts should be retained.\n 2) Walk the context stack from top to bottom, finding the first context\n that is a) a hash containing the name as a key OR b) an object responding\n to a method with the given name.\n 3) If the context is a hash, the data is the value associated with the\n name.\n 4) If the context is an object and the method with the given name has an\n arity of 1, the method SHOULD be called with a String containing the\n unprocessed contents of the sections; the data is the value returned.\n 5) Otherwise, the data is the value returned by calling the method with\n the given name.\n 6) If any name parts were retained in step 1, each should be resolved\n against a context stack containing only the result from the former\n resolution. If any part fails resolution, the result should be considered\n falsey, and should interpolate as the empty string.\nIf the data is not of a list type, it is coerced into a list as follows: if\nthe data is truthy (e.g. `!!data == true`), use a single-element list\ncontaining the data, otherwise use an empty list.\n\nThis section MUST NOT be rendered unless the data list is empty.\n\nInverted Section and End Section tags SHOULD be treated as standalone when\nappropriate.\n","tests":[{"name":"Falsey","data":{"boolean":false},"expected":"\"This should be rendered.\"","template":"\"{{^boolean}}This should be rendered.{{/boolean}}\"","desc":"Falsey sections should have their contents rendered."},{"name":"Truthy","data":{"boolean":true},"expected":"\"\"","template":"\"{{^boolean}}This should not be rendered.{{/boolean}}\"","desc":"Truthy sections should have their contents omitted."},{"name":"Context","data":{"context":{"name":"Joe"}},"expected":"\"\"","template":"\"{{^context}}Hi {{name}}.{{/context}}\"","desc":"Objects and hashes should behave like truthy values."},{"name":"List","data":{"list":[{"n":1},{"n":2},{"n":3}]},"expected":"\"\"","template":"\"{{^list}}{{n}}{{/list}}\"","desc":"Lists should behave like truthy values."},{"name":"Empty List","data":{"list":[]},"expected":"\"Yay lists!\"","template":"\"{{^list}}Yay lists!{{/list}}\"","desc":"Empty lists should behave like falsey values."},{"name":"Doubled","data":{"two":"second","bool":false},"expected":"* first\n* second\n* third\n","template":"{{^bool}}\n* first\n{{/bool}}\n* {{two}}\n{{^bool}}\n* third\n{{/bool}}\n","desc":"Multiple inverted sections per template should be permitted."},{"name":"Nested (Falsey)","data":{"bool":false},"expected":"| A B C D E |","template":"| A {{^bool}}B {{^bool}}C{{/bool}} D{{/bool}} E |","desc":"Nested falsey sections should have their contents rendered."},{"name":"Nested (Truthy)","data":{"bool":true},"expected":"| A E |","template":"| A {{^bool}}B {{^bool}}C{{/bool}} D{{/bool}} E |","desc":"Nested truthy sections should be omitted."},{"name":"Context Misses","data":{},"expected":"[Cannot find key 'missing'!]","template":"[{{^missing}}Cannot find key 'missing'!{{/missing}}]","desc":"Failed context lookups should be considered falsey."},{"name":"Dotted Names - Truthy","data":{"a":{"b":{"c":true}}},"expected":"\"\" == \"\"","template":"\"{{^a.b.c}}Not Here{{/a.b.c}}\" == \"\"","desc":"Dotted names should be valid for Inverted Section tags."},{"name":"Dotted Names - Falsey","data":{"a":{"b":{"c":false}}},"expected":"\"Not Here\" == \"Not Here\"","template":"\"{{^a.b.c}}Not Here{{/a.b.c}}\" == \"Not Here\"","desc":"Dotted names should be valid for Inverted Section tags."},{"name":"Dotted Names - Broken Chains","data":{"a":{}},"expected":"\"Not Here\" == \"Not Here\"","template":"\"{{^a.b.c}}Not Here{{/a.b.c}}\" == \"Not Here\"","desc":"Dotted names that cannot be resolved should be considered falsey."},{"name":"Surrounding Whitespace","data":{"boolean":false},"expected":" | \t|\t | \n","template":" | {{^boolean}}\t|\t{{/boolean}} | \n","desc":"Inverted sections should not alter surrounding whitespace."},{"name":"Internal Whitespace","data":{"boolean":false},"expected":" | \n | \n","template":" | {{^boolean}} {{! Important Whitespace }}\n {{/boolean}} | \n","desc":"Inverted should not alter internal whitespace."},{"name":"Indented Inline Sections","data":{"boolean":false},"expected":" NO\n WAY\n","template":" {{^boolean}}NO{{/boolean}}\n {{^boolean}}WAY{{/boolean}}\n","desc":"Single-line sections should not alter surrounding whitespace."},{"name":"Standalone Lines","data":{"boolean":false},"expected":"| This Is\n|\n| A Line\n","template":"| This Is\n{{^boolean}}\n|\n{{/boolean}}\n| A Line\n","desc":"Standalone lines should be removed from the template."},{"name":"Standalone Indented Lines","data":{"boolean":false},"expected":"| This Is\n|\n| A Line\n","template":"| This Is\n {{^boolean}}\n|\n {{/boolean}}\n| A Line\n","desc":"Standalone indented lines should be removed from the template."},{"name":"Standalone Line Endings","data":{"boolean":false},"expected":"|\r\n|","template":"|\r\n{{^boolean}}\r\n{{/boolean}}\r\n|","desc":"\"\\r\\n\" should be considered a newline for standalone tags."},{"name":"Standalone Without Previous Line","data":{"boolean":false},"expected":"^\n/","template":" {{^boolean}}\n^{{/boolean}}\n/","desc":"Standalone tags should not require a newline to precede them."},{"name":"Standalone Without Newline","data":{"boolean":false},"expected":"^\n/\n","template":"^{{^boolean}}\n/\n {{/boolean}}","desc":"Standalone tags should not require a newline to follow them."},{"name":"Padding","data":{"boolean":false},"expected":"|=|","template":"|{{^ boolean }}={{/ boolean }}|","desc":"Superfluous in-tag whitespace should be ignored."}]} -------------------------------------------------------------------------------- /tests/specs/inverted.yml: -------------------------------------------------------------------------------- 1 | overview: | 2 | Inverted Section tags and End Section tags are used in combination to wrap a 3 | section of the template. 4 | 5 | These tags' content MUST be a non-whitespace character sequence NOT 6 | containing the current closing delimiter; each Inverted Section tag MUST be 7 | followed by an End Section tag with the same content within the same 8 | section. 9 | 10 | This tag's content names the data to replace the tag. Name resolution is as 11 | follows: 12 | 1) Split the name on periods; the first part is the name to resolve, any 13 | remaining parts should be retained. 14 | 2) Walk the context stack from top to bottom, finding the first context 15 | that is a) a hash containing the name as a key OR b) an object responding 16 | to a method with the given name. 17 | 3) If the context is a hash, the data is the value associated with the 18 | name. 19 | 4) If the context is an object and the method with the given name has an 20 | arity of 1, the method SHOULD be called with a String containing the 21 | unprocessed contents of the sections; the data is the value returned. 22 | 5) Otherwise, the data is the value returned by calling the method with 23 | the given name. 24 | 6) If any name parts were retained in step 1, each should be resolved 25 | against a context stack containing only the result from the former 26 | resolution. If any part fails resolution, the result should be considered 27 | falsey, and should interpolate as the empty string. 28 | If the data is not of a list type, it is coerced into a list as follows: if 29 | the data is truthy (e.g. `!!data == true`), use a single-element list 30 | containing the data, otherwise use an empty list. 31 | 32 | This section MUST NOT be rendered unless the data list is empty. 33 | 34 | Inverted Section and End Section tags SHOULD be treated as standalone when 35 | appropriate. 36 | tests: 37 | - name: Falsey 38 | desc: Falsey sections should have their contents rendered. 39 | data: { boolean: false } 40 | template: '"{{^boolean}}This should be rendered.{{/boolean}}"' 41 | expected: '"This should be rendered."' 42 | 43 | - name: Truthy 44 | desc: Truthy sections should have their contents omitted. 45 | data: { boolean: true } 46 | template: '"{{^boolean}}This should not be rendered.{{/boolean}}"' 47 | expected: '""' 48 | 49 | - name: Context 50 | desc: Objects and hashes should behave like truthy values. 51 | data: { context: { name: 'Joe' } } 52 | template: '"{{^context}}Hi {{name}}.{{/context}}"' 53 | expected: '""' 54 | 55 | - name: List 56 | desc: Lists should behave like truthy values. 57 | data: { list: [ { n: 1 }, { n: 2 }, { n: 3 } ] } 58 | template: '"{{^list}}{{n}}{{/list}}"' 59 | expected: '""' 60 | 61 | - name: Empty List 62 | desc: Empty lists should behave like falsey values. 63 | data: { list: [ ] } 64 | template: '"{{^list}}Yay lists!{{/list}}"' 65 | expected: '"Yay lists!"' 66 | 67 | - name: Doubled 68 | desc: Multiple inverted sections per template should be permitted. 69 | data: { bool: false, two: 'second' } 70 | template: | 71 | {{^bool}} 72 | * first 73 | {{/bool}} 74 | * {{two}} 75 | {{^bool}} 76 | * third 77 | {{/bool}} 78 | expected: | 79 | * first 80 | * second 81 | * third 82 | 83 | - name: Nested (Falsey) 84 | desc: Nested falsey sections should have their contents rendered. 85 | data: { bool: false } 86 | template: "| A {{^bool}}B {{^bool}}C{{/bool}} D{{/bool}} E |" 87 | expected: "| A B C D E |" 88 | 89 | - name: Nested (Truthy) 90 | desc: Nested truthy sections should be omitted. 91 | data: { bool: true } 92 | template: "| A {{^bool}}B {{^bool}}C{{/bool}} D{{/bool}} E |" 93 | expected: "| A E |" 94 | 95 | - name: Context Misses 96 | desc: Failed context lookups should be considered falsey. 97 | data: { } 98 | template: "[{{^missing}}Cannot find key 'missing'!{{/missing}}]" 99 | expected: "[Cannot find key 'missing'!]" 100 | 101 | # Dotted Names 102 | 103 | - name: Dotted Names - Truthy 104 | desc: Dotted names should be valid for Inverted Section tags. 105 | data: { a: { b: { c: true } } } 106 | template: '"{{^a.b.c}}Not Here{{/a.b.c}}" == ""' 107 | expected: '"" == ""' 108 | 109 | - name: Dotted Names - Falsey 110 | desc: Dotted names should be valid for Inverted Section tags. 111 | data: { a: { b: { c: false } } } 112 | template: '"{{^a.b.c}}Not Here{{/a.b.c}}" == "Not Here"' 113 | expected: '"Not Here" == "Not Here"' 114 | 115 | - name: Dotted Names - Broken Chains 116 | desc: Dotted names that cannot be resolved should be considered falsey. 117 | data: { a: { } } 118 | template: '"{{^a.b.c}}Not Here{{/a.b.c}}" == "Not Here"' 119 | expected: '"Not Here" == "Not Here"' 120 | 121 | # Whitespace Sensitivity 122 | 123 | - name: Surrounding Whitespace 124 | desc: Inverted sections should not alter surrounding whitespace. 125 | data: { boolean: false } 126 | template: " | {{^boolean}}\t|\t{{/boolean}} | \n" 127 | expected: " | \t|\t | \n" 128 | 129 | - name: Internal Whitespace 130 | desc: Inverted should not alter internal whitespace. 131 | data: { boolean: false } 132 | template: " | {{^boolean}} {{! Important Whitespace }}\n {{/boolean}} | \n" 133 | expected: " | \n | \n" 134 | 135 | - name: Indented Inline Sections 136 | desc: Single-line sections should not alter surrounding whitespace. 137 | data: { boolean: false } 138 | template: " {{^boolean}}NO{{/boolean}}\n {{^boolean}}WAY{{/boolean}}\n" 139 | expected: " NO\n WAY\n" 140 | 141 | - name: Standalone Lines 142 | desc: Standalone lines should be removed from the template. 143 | data: { boolean: false } 144 | template: | 145 | | This Is 146 | {{^boolean}} 147 | | 148 | {{/boolean}} 149 | | A Line 150 | expected: | 151 | | This Is 152 | | 153 | | A Line 154 | 155 | - name: Standalone Indented Lines 156 | desc: Standalone indented lines should be removed from the template. 157 | data: { boolean: false } 158 | template: | 159 | | This Is 160 | {{^boolean}} 161 | | 162 | {{/boolean}} 163 | | A Line 164 | expected: | 165 | | This Is 166 | | 167 | | A Line 168 | 169 | - name: Standalone Line Endings 170 | desc: '"\r\n" should be considered a newline for standalone tags.' 171 | data: { boolean: false } 172 | template: "|\r\n{{^boolean}}\r\n{{/boolean}}\r\n|" 173 | expected: "|\r\n|" 174 | 175 | - name: Standalone Without Previous Line 176 | desc: Standalone tags should not require a newline to precede them. 177 | data: { boolean: false } 178 | template: " {{^boolean}}\n^{{/boolean}}\n/" 179 | expected: "^\n/" 180 | 181 | - name: Standalone Without Newline 182 | desc: Standalone tags should not require a newline to follow them. 183 | data: { boolean: false } 184 | template: "^{{^boolean}}\n/\n {{/boolean}}" 185 | expected: "^\n/\n" 186 | 187 | # Whitespace Insensitivity 188 | 189 | - name: Padding 190 | desc: Superfluous in-tag whitespace should be ignored. 191 | data: { boolean: false } 192 | template: '|{{^ boolean }}={{/ boolean }}|' 193 | expected: '|=|' 194 | -------------------------------------------------------------------------------- /tests/specs/partials.json: -------------------------------------------------------------------------------- 1 | {"__ATTN__":"Do not edit this file; changes belong in the appropriate YAML file.","overview":"Partial tags are used to expand an external template into the current\ntemplate.\n\nThe tag's content MUST be a non-whitespace character sequence NOT containing\nthe current closing delimiter.\n\nThis tag's content names the partial to inject. Set Delimiter tags MUST NOT\naffect the parsing of a partial. The partial MUST be rendered against the\ncontext stack local to the tag. If the named partial cannot be found, the\nempty string SHOULD be used instead, as in interpolations.\n\nPartial tags SHOULD be treated as standalone when appropriate. If this tag\nis used standalone, any whitespace preceding the tag should treated as\nindentation, and prepended to each line of the partial before rendering.\n","tests":[{"name":"Basic Behavior","data":{},"expected":"\"from partial\"","template":"\"{{>text}}\"","desc":"The greater-than operator should expand to the named partial.","partials":{"text":"from partial"}},{"name":"Failed Lookup","data":{},"expected":"\"\"","template":"\"{{>text}}\"","desc":"The empty string should be used when the named partial is not found.","partials":{}},{"name":"Context","data":{"text":"content"},"expected":"\"*content*\"","template":"\"{{>partial}}\"","desc":"The greater-than operator should operate within the current context.","partials":{"partial":"*{{text}}*"}},{"name":"Recursion","data":{"content":"X","nodes":[{"content":"Y","nodes":[]}]},"expected":"X>","template":"{{>node}}","desc":"The greater-than operator should properly recurse.","partials":{"node":"{{content}}<{{#nodes}}{{>node}}{{/nodes}}>"}},{"name":"Surrounding Whitespace","data":{},"expected":"| \t|\t |","template":"| {{>partial}} |","desc":"The greater-than operator should not alter surrounding whitespace.","partials":{"partial":"\t|\t"}},{"name":"Inline Indentation","data":{"data":"|"},"expected":" | >\n>\n","template":" {{data}} {{> partial}}\n","desc":"Whitespace should be left untouched.","partials":{"partial":">\n>"}},{"name":"Standalone Line Endings","data":{},"expected":"|\r\n>|","template":"|\r\n{{>partial}}\r\n|","desc":"\"\\r\\n\" should be considered a newline for standalone tags.","partials":{"partial":">"}},{"name":"Standalone Without Previous Line","data":{},"expected":" >\n >>","template":" {{>partial}}\n>","desc":"Standalone tags should not require a newline to precede them.","partials":{"partial":">\n>"}},{"name":"Standalone Without Newline","data":{},"expected":">\n >\n >","template":">\n {{>partial}}","desc":"Standalone tags should not require a newline to follow them.","partials":{"partial":">\n>"}},{"name":"Standalone Indentation","data":{"content":"<\n->"},"expected":"\\\n |\n <\n->\n |\n/\n","template":"\\\n {{>partial}}\n/\n","desc":"Each line of the partial should be indented before rendering.","partials":{"partial":"|\n{{{content}}}\n|\n"}},{"name":"Padding Whitespace","data":{"boolean":true},"expected":"|[]|","template":"|{{> partial }}|","desc":"Superfluous in-tag whitespace should be ignored.","partials":{"partial":"[]"}}]} -------------------------------------------------------------------------------- /tests/specs/partials.yml: -------------------------------------------------------------------------------- 1 | overview: | 2 | Partial tags are used to expand an external template into the current 3 | template. 4 | 5 | The tag's content MUST be a non-whitespace character sequence NOT containing 6 | the current closing delimiter. 7 | 8 | This tag's content names the partial to inject. Set Delimiter tags MUST NOT 9 | affect the parsing of a partial. The partial MUST be rendered against the 10 | context stack local to the tag. If the named partial cannot be found, the 11 | empty string SHOULD be used instead, as in interpolations. 12 | 13 | Partial tags SHOULD be treated as standalone when appropriate. If this tag 14 | is used standalone, any whitespace preceding the tag should treated as 15 | indentation, and prepended to each line of the partial before rendering. 16 | tests: 17 | - name: Basic Behavior 18 | desc: The greater-than operator should expand to the named partial. 19 | data: { } 20 | template: '"{{>text}}"' 21 | partials: { text: 'from partial' } 22 | expected: '"from partial"' 23 | 24 | - name: Failed Lookup 25 | desc: The empty string should be used when the named partial is not found. 26 | data: { } 27 | template: '"{{>text}}"' 28 | partials: { } 29 | expected: '""' 30 | 31 | - name: Context 32 | desc: The greater-than operator should operate within the current context. 33 | data: { text: 'content' } 34 | template: '"{{>partial}}"' 35 | partials: { partial: '*{{text}}*' } 36 | expected: '"*content*"' 37 | 38 | - name: Recursion 39 | desc: The greater-than operator should properly recurse. 40 | data: { content: "X", nodes: [ { content: "Y", nodes: [] } ] } 41 | template: '{{>node}}' 42 | partials: { node: '{{content}}<{{#nodes}}{{>node}}{{/nodes}}>' } 43 | expected: 'X>' 44 | 45 | # Whitespace Sensitivity 46 | 47 | - name: Surrounding Whitespace 48 | desc: The greater-than operator should not alter surrounding whitespace. 49 | data: { } 50 | template: '| {{>partial}} |' 51 | partials: { partial: "\t|\t" } 52 | expected: "| \t|\t |" 53 | 54 | - name: Inline Indentation 55 | desc: Whitespace should be left untouched. 56 | data: { data: '|' } 57 | template: " {{data}} {{> partial}}\n" 58 | partials: { partial: ">\n>" } 59 | expected: " | >\n>\n" 60 | 61 | - name: Standalone Line Endings 62 | desc: '"\r\n" should be considered a newline for standalone tags.' 63 | data: { } 64 | template: "|\r\n{{>partial}}\r\n|" 65 | partials: { partial: ">" } 66 | expected: "|\r\n>|" 67 | 68 | - name: Standalone Without Previous Line 69 | desc: Standalone tags should not require a newline to precede them. 70 | data: { } 71 | template: " {{>partial}}\n>" 72 | partials: { partial: ">\n>"} 73 | expected: " >\n >>" 74 | 75 | - name: Standalone Without Newline 76 | desc: Standalone tags should not require a newline to follow them. 77 | data: { } 78 | template: ">\n {{>partial}}" 79 | partials: { partial: ">\n>" } 80 | expected: ">\n >\n >" 81 | 82 | - name: Standalone Indentation 83 | desc: Each line of the partial should be indented before rendering. 84 | data: { content: "<\n->" } 85 | template: | 86 | \ 87 | {{>partial}} 88 | / 89 | partials: 90 | partial: | 91 | | 92 | {{{content}}} 93 | | 94 | expected: | 95 | \ 96 | | 97 | < 98 | -> 99 | | 100 | / 101 | 102 | # Whitespace Insensitivity 103 | 104 | - name: Padding Whitespace 105 | desc: Superfluous in-tag whitespace should be ignored. 106 | data: { boolean: true } 107 | template: "|{{> partial }}|" 108 | partials: { partial: "[]" } 109 | expected: '|[]|' 110 | -------------------------------------------------------------------------------- /tests/specs/sections.json: -------------------------------------------------------------------------------- 1 | {"__ATTN__":"Do not edit this file; changes belong in the appropriate YAML file.","overview":"Section tags and End Section tags are used in combination to wrap a section\nof the template for iteration\n\nThese tags' content MUST be a non-whitespace character sequence NOT\ncontaining the current closing delimiter; each Section tag MUST be followed\nby an End Section tag with the same content within the same section.\n\nThis tag's content names the data to replace the tag. Name resolution is as\nfollows:\n 1) Split the name on periods; the first part is the name to resolve, any\n remaining parts should be retained.\n 2) Walk the context stack from top to bottom, finding the first context\n that is a) a hash containing the name as a key OR b) an object responding\n to a method with the given name.\n 3) If the context is a hash, the data is the value associated with the\n name.\n 4) If the context is an object and the method with the given name has an\n arity of 1, the method SHOULD be called with a String containing the\n unprocessed contents of the sections; the data is the value returned.\n 5) Otherwise, the data is the value returned by calling the method with\n the given name.\n 6) If any name parts were retained in step 1, each should be resolved\n against a context stack containing only the result from the former\n resolution. If any part fails resolution, the result should be considered\n falsey, and should interpolate as the empty string.\nIf the data is not of a list type, it is coerced into a list as follows: if\nthe data is truthy (e.g. `!!data == true`), use a single-element list\ncontaining the data, otherwise use an empty list.\n\nFor each element in the data list, the element MUST be pushed onto the\ncontext stack, the section MUST be rendered, and the element MUST be popped\noff the context stack.\n\nSection and End Section tags SHOULD be treated as standalone when\nappropriate.\n","tests":[{"name":"Truthy","data":{"boolean":true},"expected":"\"This should be rendered.\"","template":"\"{{#boolean}}This should be rendered.{{/boolean}}\"","desc":"Truthy sections should have their contents rendered."},{"name":"Falsey","data":{"boolean":false},"expected":"\"\"","template":"\"{{#boolean}}This should not be rendered.{{/boolean}}\"","desc":"Falsey sections should have their contents omitted."},{"name":"Context","data":{"context":{"name":"Joe"}},"expected":"\"Hi Joe.\"","template":"\"{{#context}}Hi {{name}}.{{/context}}\"","desc":"Objects and hashes should be pushed onto the context stack."},{"name":"Deeply Nested Contexts","data":{"a":{"one":1},"b":{"two":2},"c":{"three":3},"d":{"four":4},"e":{"five":5}},"expected":"1\n121\n12321\n1234321\n123454321\n1234321\n12321\n121\n1\n","template":"{{#a}}\n{{one}}\n{{#b}}\n{{one}}{{two}}{{one}}\n{{#c}}\n{{one}}{{two}}{{three}}{{two}}{{one}}\n{{#d}}\n{{one}}{{two}}{{three}}{{four}}{{three}}{{two}}{{one}}\n{{#e}}\n{{one}}{{two}}{{three}}{{four}}{{five}}{{four}}{{three}}{{two}}{{one}}\n{{/e}}\n{{one}}{{two}}{{three}}{{four}}{{three}}{{two}}{{one}}\n{{/d}}\n{{one}}{{two}}{{three}}{{two}}{{one}}\n{{/c}}\n{{one}}{{two}}{{one}}\n{{/b}}\n{{one}}\n{{/a}}\n","desc":"All elements on the context stack should be accessible."},{"name":"List","data":{"list":[{"item":1},{"item":2},{"item":3}]},"expected":"\"123\"","template":"\"{{#list}}{{item}}{{/list}}\"","desc":"Lists should be iterated; list items should visit the context stack."},{"name":"Empty List","data":{"list":[]},"expected":"\"\"","template":"\"{{#list}}Yay lists!{{/list}}\"","desc":"Empty lists should behave like falsey values."},{"name":"Doubled","data":{"two":"second","bool":true},"expected":"* first\n* second\n* third\n","template":"{{#bool}}\n* first\n{{/bool}}\n* {{two}}\n{{#bool}}\n* third\n{{/bool}}\n","desc":"Multiple sections per template should be permitted."},{"name":"Nested (Truthy)","data":{"bool":true},"expected":"| A B C D E |","template":"| A {{#bool}}B {{#bool}}C{{/bool}} D{{/bool}} E |","desc":"Nested truthy sections should have their contents rendered."},{"name":"Nested (Falsey)","data":{"bool":false},"expected":"| A E |","template":"| A {{#bool}}B {{#bool}}C{{/bool}} D{{/bool}} E |","desc":"Nested falsey sections should be omitted."},{"name":"Context Misses","data":{},"expected":"[]","template":"[{{#missing}}Found key 'missing'!{{/missing}}]","desc":"Failed context lookups should be considered falsey."},{"name":"Implicit Iterator - String","data":{"list":["a","b","c","d","e"]},"expected":"\"(a)(b)(c)(d)(e)\"","template":"\"{{#list}}({{.}}){{/list}}\"","desc":"Implicit iterators should directly interpolate strings."},{"name":"Implicit Iterator - Integer","data":{"list":[1,2,3,4,5]},"expected":"\"(1)(2)(3)(4)(5)\"","template":"\"{{#list}}({{.}}){{/list}}\"","desc":"Implicit iterators should cast integers to strings and interpolate."},{"name":"Implicit Iterator - Decimal","data":{"list":[1.1,2.2,3.3,4.4,5.5]},"expected":"\"(1.1)(2.2)(3.3)(4.4)(5.5)\"","template":"\"{{#list}}({{.}}){{/list}}\"","desc":"Implicit iterators should cast decimals to strings and interpolate."},{"name":"Dotted Names - Truthy","data":{"a":{"b":{"c":true}}},"expected":"\"Here\" == \"Here\"","template":"\"{{#a.b.c}}Here{{/a.b.c}}\" == \"Here\"","desc":"Dotted names should be valid for Section tags."},{"name":"Dotted Names - Falsey","data":{"a":{"b":{"c":false}}},"expected":"\"\" == \"\"","template":"\"{{#a.b.c}}Here{{/a.b.c}}\" == \"\"","desc":"Dotted names should be valid for Section tags."},{"name":"Dotted Names - Broken Chains","data":{"a":{}},"expected":"\"\" == \"\"","template":"\"{{#a.b.c}}Here{{/a.b.c}}\" == \"\"","desc":"Dotted names that cannot be resolved should be considered falsey."},{"name":"Surrounding Whitespace","data":{"boolean":true},"expected":" | \t|\t | \n","template":" | {{#boolean}}\t|\t{{/boolean}} | \n","desc":"Sections should not alter surrounding whitespace."},{"name":"Internal Whitespace","data":{"boolean":true},"expected":" | \n | \n","template":" | {{#boolean}} {{! Important Whitespace }}\n {{/boolean}} | \n","desc":"Sections should not alter internal whitespace."},{"name":"Indented Inline Sections","data":{"boolean":true},"expected":" YES\n GOOD\n","template":" {{#boolean}}YES{{/boolean}}\n {{#boolean}}GOOD{{/boolean}}\n","desc":"Single-line sections should not alter surrounding whitespace."},{"name":"Standalone Lines","data":{"boolean":true},"expected":"| This Is\n|\n| A Line\n","template":"| This Is\n{{#boolean}}\n|\n{{/boolean}}\n| A Line\n","desc":"Standalone lines should be removed from the template."},{"name":"Indented Standalone Lines","data":{"boolean":true},"expected":"| This Is\n|\n| A Line\n","template":"| This Is\n {{#boolean}}\n|\n {{/boolean}}\n| A Line\n","desc":"Indented standalone lines should be removed from the template."},{"name":"Standalone Line Endings","data":{"boolean":true},"expected":"|\r\n|","template":"|\r\n{{#boolean}}\r\n{{/boolean}}\r\n|","desc":"\"\\r\\n\" should be considered a newline for standalone tags."},{"name":"Standalone Without Previous Line","data":{"boolean":true},"expected":"#\n/","template":" {{#boolean}}\n#{{/boolean}}\n/","desc":"Standalone tags should not require a newline to precede them."},{"name":"Standalone Without Newline","data":{"boolean":true},"expected":"#\n/\n","template":"#{{#boolean}}\n/\n {{/boolean}}","desc":"Standalone tags should not require a newline to follow them."},{"name":"Padding","data":{"boolean":true},"expected":"|=|","template":"|{{# boolean }}={{/ boolean }}|","desc":"Superfluous in-tag whitespace should be ignored."}]} -------------------------------------------------------------------------------- /tests/specs/sections.yml: -------------------------------------------------------------------------------- 1 | overview: | 2 | Section tags and End Section tags are used in combination to wrap a section 3 | of the template for iteration 4 | 5 | These tags' content MUST be a non-whitespace character sequence NOT 6 | containing the current closing delimiter; each Section tag MUST be followed 7 | by an End Section tag with the same content within the same section. 8 | 9 | This tag's content names the data to replace the tag. Name resolution is as 10 | follows: 11 | 1) Split the name on periods; the first part is the name to resolve, any 12 | remaining parts should be retained. 13 | 2) Walk the context stack from top to bottom, finding the first context 14 | that is a) a hash containing the name as a key OR b) an object responding 15 | to a method with the given name. 16 | 3) If the context is a hash, the data is the value associated with the 17 | name. 18 | 4) If the context is an object and the method with the given name has an 19 | arity of 1, the method SHOULD be called with a String containing the 20 | unprocessed contents of the sections; the data is the value returned. 21 | 5) Otherwise, the data is the value returned by calling the method with 22 | the given name. 23 | 6) If any name parts were retained in step 1, each should be resolved 24 | against a context stack containing only the result from the former 25 | resolution. If any part fails resolution, the result should be considered 26 | falsey, and should interpolate as the empty string. 27 | If the data is not of a list type, it is coerced into a list as follows: if 28 | the data is truthy (e.g. `!!data == true`), use a single-element list 29 | containing the data, otherwise use an empty list. 30 | 31 | For each element in the data list, the element MUST be pushed onto the 32 | context stack, the section MUST be rendered, and the element MUST be popped 33 | off the context stack. 34 | 35 | Section and End Section tags SHOULD be treated as standalone when 36 | appropriate. 37 | tests: 38 | - name: Truthy 39 | desc: Truthy sections should have their contents rendered. 40 | data: { boolean: true } 41 | template: '"{{#boolean}}This should be rendered.{{/boolean}}"' 42 | expected: '"This should be rendered."' 43 | 44 | - name: Falsey 45 | desc: Falsey sections should have their contents omitted. 46 | data: { boolean: false } 47 | template: '"{{#boolean}}This should not be rendered.{{/boolean}}"' 48 | expected: '""' 49 | 50 | - name: Context 51 | desc: Objects and hashes should be pushed onto the context stack. 52 | data: { context: { name: 'Joe' } } 53 | template: '"{{#context}}Hi {{name}}.{{/context}}"' 54 | expected: '"Hi Joe."' 55 | 56 | - name: Deeply Nested Contexts 57 | desc: All elements on the context stack should be accessible. 58 | data: 59 | a: { one: 1 } 60 | b: { two: 2 } 61 | c: { three: 3 } 62 | d: { four: 4 } 63 | e: { five: 5 } 64 | template: | 65 | {{#a}} 66 | {{one}} 67 | {{#b}} 68 | {{one}}{{two}}{{one}} 69 | {{#c}} 70 | {{one}}{{two}}{{three}}{{two}}{{one}} 71 | {{#d}} 72 | {{one}}{{two}}{{three}}{{four}}{{three}}{{two}}{{one}} 73 | {{#e}} 74 | {{one}}{{two}}{{three}}{{four}}{{five}}{{four}}{{three}}{{two}}{{one}} 75 | {{/e}} 76 | {{one}}{{two}}{{three}}{{four}}{{three}}{{two}}{{one}} 77 | {{/d}} 78 | {{one}}{{two}}{{three}}{{two}}{{one}} 79 | {{/c}} 80 | {{one}}{{two}}{{one}} 81 | {{/b}} 82 | {{one}} 83 | {{/a}} 84 | expected: | 85 | 1 86 | 121 87 | 12321 88 | 1234321 89 | 123454321 90 | 1234321 91 | 12321 92 | 121 93 | 1 94 | 95 | - name: List 96 | desc: Lists should be iterated; list items should visit the context stack. 97 | data: { list: [ { item: 1 }, { item: 2 }, { item: 3 } ] } 98 | template: '"{{#list}}{{item}}{{/list}}"' 99 | expected: '"123"' 100 | 101 | - name: Empty List 102 | desc: Empty lists should behave like falsey values. 103 | data: { list: [ ] } 104 | template: '"{{#list}}Yay lists!{{/list}}"' 105 | expected: '""' 106 | 107 | - name: Doubled 108 | desc: Multiple sections per template should be permitted. 109 | data: { bool: true, two: 'second' } 110 | template: | 111 | {{#bool}} 112 | * first 113 | {{/bool}} 114 | * {{two}} 115 | {{#bool}} 116 | * third 117 | {{/bool}} 118 | expected: | 119 | * first 120 | * second 121 | * third 122 | 123 | - name: Nested (Truthy) 124 | desc: Nested truthy sections should have their contents rendered. 125 | data: { bool: true } 126 | template: "| A {{#bool}}B {{#bool}}C{{/bool}} D{{/bool}} E |" 127 | expected: "| A B C D E |" 128 | 129 | - name: Nested (Falsey) 130 | desc: Nested falsey sections should be omitted. 131 | data: { bool: false } 132 | template: "| A {{#bool}}B {{#bool}}C{{/bool}} D{{/bool}} E |" 133 | expected: "| A E |" 134 | 135 | - name: Context Misses 136 | desc: Failed context lookups should be considered falsey. 137 | data: { } 138 | template: "[{{#missing}}Found key 'missing'!{{/missing}}]" 139 | expected: "[]" 140 | 141 | # Implicit Iterators 142 | 143 | - name: Implicit Iterator - String 144 | desc: Implicit iterators should directly interpolate strings. 145 | data: 146 | list: [ 'a', 'b', 'c', 'd', 'e' ] 147 | template: '"{{#list}}({{.}}){{/list}}"' 148 | expected: '"(a)(b)(c)(d)(e)"' 149 | 150 | - name: Implicit Iterator - Integer 151 | desc: Implicit iterators should cast integers to strings and interpolate. 152 | data: 153 | list: [ 1, 2, 3, 4, 5 ] 154 | template: '"{{#list}}({{.}}){{/list}}"' 155 | expected: '"(1)(2)(3)(4)(5)"' 156 | 157 | - name: Implicit Iterator - Decimal 158 | desc: Implicit iterators should cast decimals to strings and interpolate. 159 | data: 160 | list: [ 1.10, 2.20, 3.30, 4.40, 5.50 ] 161 | template: '"{{#list}}({{.}}){{/list}}"' 162 | expected: '"(1.1)(2.2)(3.3)(4.4)(5.5)"' 163 | 164 | # Dotted Names 165 | 166 | - name: Dotted Names - Truthy 167 | desc: Dotted names should be valid for Section tags. 168 | data: { a: { b: { c: true } } } 169 | template: '"{{#a.b.c}}Here{{/a.b.c}}" == "Here"' 170 | expected: '"Here" == "Here"' 171 | 172 | - name: Dotted Names - Falsey 173 | desc: Dotted names should be valid for Section tags. 174 | data: { a: { b: { c: false } } } 175 | template: '"{{#a.b.c}}Here{{/a.b.c}}" == ""' 176 | expected: '"" == ""' 177 | 178 | - name: Dotted Names - Broken Chains 179 | desc: Dotted names that cannot be resolved should be considered falsey. 180 | data: { a: { } } 181 | template: '"{{#a.b.c}}Here{{/a.b.c}}" == ""' 182 | expected: '"" == ""' 183 | 184 | # Whitespace Sensitivity 185 | 186 | - name: Surrounding Whitespace 187 | desc: Sections should not alter surrounding whitespace. 188 | data: { boolean: true } 189 | template: " | {{#boolean}}\t|\t{{/boolean}} | \n" 190 | expected: " | \t|\t | \n" 191 | 192 | - name: Internal Whitespace 193 | desc: Sections should not alter internal whitespace. 194 | data: { boolean: true } 195 | template: " | {{#boolean}} {{! Important Whitespace }}\n {{/boolean}} | \n" 196 | expected: " | \n | \n" 197 | 198 | - name: Indented Inline Sections 199 | desc: Single-line sections should not alter surrounding whitespace. 200 | data: { boolean: true } 201 | template: " {{#boolean}}YES{{/boolean}}\n {{#boolean}}GOOD{{/boolean}}\n" 202 | expected: " YES\n GOOD\n" 203 | 204 | - name: Standalone Lines 205 | desc: Standalone lines should be removed from the template. 206 | data: { boolean: true } 207 | template: | 208 | | This Is 209 | {{#boolean}} 210 | | 211 | {{/boolean}} 212 | | A Line 213 | expected: | 214 | | This Is 215 | | 216 | | A Line 217 | 218 | - name: Indented Standalone Lines 219 | desc: Indented standalone lines should be removed from the template. 220 | data: { boolean: true } 221 | template: | 222 | | This Is 223 | {{#boolean}} 224 | | 225 | {{/boolean}} 226 | | A Line 227 | expected: | 228 | | This Is 229 | | 230 | | A Line 231 | 232 | - name: Standalone Line Endings 233 | desc: '"\r\n" should be considered a newline for standalone tags.' 234 | data: { boolean: true } 235 | template: "|\r\n{{#boolean}}\r\n{{/boolean}}\r\n|" 236 | expected: "|\r\n|" 237 | 238 | - name: Standalone Without Previous Line 239 | desc: Standalone tags should not require a newline to precede them. 240 | data: { boolean: true } 241 | template: " {{#boolean}}\n#{{/boolean}}\n/" 242 | expected: "#\n/" 243 | 244 | - name: Standalone Without Newline 245 | desc: Standalone tags should not require a newline to follow them. 246 | data: { boolean: true } 247 | template: "#{{#boolean}}\n/\n {{/boolean}}" 248 | expected: "#\n/\n" 249 | 250 | # Whitespace Insensitivity 251 | 252 | - name: Padding 253 | desc: Superfluous in-tag whitespace should be ignored. 254 | data: { boolean: true } 255 | template: '|{{# boolean }}={{/ boolean }}|' 256 | expected: '|=|' 257 | -------------------------------------------------------------------------------- /tests/test_mustache.cpp: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2012, Robert Knight 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | 10 | Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | */ 14 | 15 | #include "test_mustache.h" 16 | 17 | #include 18 | #include 19 | #include 20 | #include 21 | #include 22 | 23 | #if QT_VERSION >= 0x050000 24 | #include 25 | #include 26 | #include 27 | #endif // QT_VERSION >= 0x050000 28 | 29 | // To be able to use QHash in QFETCH(..). 30 | typedef QHash PartialsHash; 31 | Q_DECLARE_METATYPE(PartialsHash) 32 | 33 | void TestMustache::testValues() 34 | { 35 | QVariantHash map; 36 | map["name"] = "John Smith"; 37 | map["age"] = 42; 38 | map["sex"] = "Male"; 39 | map["company"] = "Smith & Co"; 40 | map["signature"] = "John Smith of Smith & Co"; 41 | map["alive"] = false; 42 | 43 | QString _template = "Name: {{name}}, Age: {{age}}, Sex: {{sex}}, Alive: {{alive}}\n" 44 | "Company: {{company}}\n" 45 | " {{{signature}}}" 46 | "{{missing-key}}"; 47 | QString expectedOutput = "Name: John Smith, Age: 42, Sex: Male, Alive: false\n" 48 | "Company: Smith & Co\n" 49 | " John Smith of Smith & Co"; 50 | 51 | Mustache::Renderer renderer; 52 | Mustache::QtVariantContext context(map); 53 | QString output = renderer.render(_template, &context); 54 | 55 | QCOMPARE(output, expectedOutput); 56 | } 57 | 58 | void TestMustache::testFloatValues() 59 | { 60 | QList values = {-3., -0.5, 0., 0.5, 1., 1.5, 3.}; 61 | 62 | for (auto value : values) { 63 | QVariantHash map; 64 | map["val"] = value; 65 | QString _template = "Value: {{val}}"; 66 | QString expectedOutput = "Value: " + QString::number(value); 67 | 68 | Mustache::Renderer renderer; 69 | Mustache::QtVariantContext context(map); 70 | QString output = renderer.render(_template, &context); 71 | 72 | QCOMPARE(output, expectedOutput); 73 | } 74 | } 75 | 76 | QVariantHash contactInfo(const QString& name, const QString& email) 77 | { 78 | QVariantHash map; 79 | map["name"] = name; 80 | map["email"] = email; 81 | return map; 82 | } 83 | 84 | void TestMustache::testSections() 85 | { 86 | QVariantHash map = contactInfo("John Smith", "john.smith@gmail.com"); 87 | QVariantList contacts; 88 | contacts << contactInfo("James Dee", "james@dee.org"); 89 | contacts << contactInfo("Jim Jones", "jim-jones@yahoo.com"); 90 | map["contacts"] = contacts; 91 | 92 | QString _template = "Name: {{name}}, Email: {{email}}\n" 93 | "{{#contacts}} {{name}} - {{email}}\n{{/contacts}}" 94 | "{{^contacts}} No contacts{{/contacts}}"; 95 | 96 | QString expectedOutput = "Name: John Smith, Email: john.smith@gmail.com\n" 97 | " James Dee - james@dee.org\n" 98 | " Jim Jones - jim-jones@yahoo.com\n"; 99 | 100 | Mustache::Renderer renderer; 101 | Mustache::QtVariantContext context(map); 102 | QString output = renderer.render(_template, &context); 103 | 104 | QCOMPARE(output, expectedOutput); 105 | 106 | // test inverted sections 107 | map.remove("contacts"); 108 | context = Mustache::QtVariantContext(map); 109 | output = renderer.render(_template, &context); 110 | 111 | expectedOutput = "Name: John Smith, Email: john.smith@gmail.com\n" 112 | " No contacts"; 113 | QCOMPARE(output, expectedOutput); 114 | 115 | // test with an empty list instead of an empty key 116 | map["contacts"] = QVariantHash(); 117 | context = Mustache::QtVariantContext(map); 118 | output = renderer.render(_template, &context); 119 | QCOMPARE(output, expectedOutput); 120 | } 121 | 122 | void TestMustache::testSectionQString() 123 | { 124 | QVariantHash data; 125 | data["text"] = "test"; 126 | QString output = Mustache::renderTemplate("{{#text}}{{text}}{{/text}}", data); 127 | QCOMPARE(output, QString("test")); 128 | } 129 | 130 | void TestMustache::testFalsiness() 131 | { 132 | Mustache::Renderer renderer; 133 | QVariantHash data; 134 | QString _template = "{{#bool}}This should not be shown{{/bool}}"; 135 | 136 | // test falsiness of 0 137 | data["bool"] = 0; 138 | Mustache::QtVariantContext context = Mustache::QtVariantContext(data); 139 | QString output = renderer.render(_template, &context); 140 | QVERIFY2(output.isEmpty(), "0 evaluated as truthy"); 141 | 142 | // test falsiness of 0u 143 | data["bool"] = 0u; 144 | context = Mustache::QtVariantContext(data); 145 | output = renderer.render(_template, &context); 146 | QVERIFY2(output.isEmpty(), "0u evaluated as truthy"); 147 | 148 | // test falsiness of 0ll 149 | data["bool"] = 0ll; 150 | context = Mustache::QtVariantContext(data); 151 | output = renderer.render(_template, &context); 152 | QVERIFY2(output.isEmpty(), "0ll evaluated as truthy"); 153 | 154 | // test falsiness of 0ull 155 | data["bool"] = 0ull; 156 | context = Mustache::QtVariantContext(data); 157 | output = renderer.render(_template, &context); 158 | QVERIFY2(output.isEmpty(), "0ull evaluated as truthy"); 159 | 160 | // test falsiness of 0.0 161 | data["bool"] = 0.0; 162 | context = Mustache::QtVariantContext(data); 163 | output = renderer.render(_template, &context); 164 | QVERIFY2(output.isEmpty(), "0.0 evaluated as truthy"); 165 | 166 | // test falsiness of 0.4 167 | data["bool"] = 0.4f; 168 | context = Mustache::QtVariantContext(data); 169 | output = renderer.render(_template, &context); 170 | QVERIFY2(!output.isEmpty(), "0.4 evaluated as falsey"); 171 | 172 | // test falsiness of 0.5 173 | data["bool"] = 0.5f; 174 | context = Mustache::QtVariantContext(data); 175 | output = renderer.render(_template, &context); 176 | QVERIFY2(!output.isEmpty(), "0.5f evaluated as falsey"); 177 | 178 | // test falsiness of 0.0f 179 | data["bool"] = 0.0f; 180 | context = Mustache::QtVariantContext(data); 181 | output = renderer.render(_template, &context); 182 | QVERIFY2(output.isEmpty(), "0.0f evaluated as truthy"); 183 | 184 | // test falsiness of '\0' 185 | data["bool"] = '\0'; 186 | context = Mustache::QtVariantContext(data); 187 | output = renderer.render(_template, &context); 188 | QVERIFY2(output.isEmpty(), "'\0' evaluated as truthy"); 189 | 190 | // test falsiness of 'false' 191 | data["bool"] = false; 192 | context = Mustache::QtVariantContext(data); 193 | output = renderer.render(_template, &context); 194 | QVERIFY2(output.isEmpty(), "'\0' evaluated as truthy"); 195 | } 196 | 197 | void TestMustache::testContextLookup() 198 | { 199 | QVariantHash fileMap; 200 | fileMap["dir"] = "/home/robert"; 201 | fileMap["name"] = "robert"; 202 | 203 | QVariantList files; 204 | QVariantHash file; 205 | file["name"] = "test.pdf"; 206 | files << file; 207 | 208 | fileMap["files"] = files; 209 | 210 | QString _template = "{{#files}}{{dir}}/{{name}}{{/files}}"; 211 | 212 | Mustache::Renderer renderer; 213 | Mustache::QtVariantContext context(fileMap); 214 | QString output = renderer.render(_template, &context); 215 | 216 | QCOMPARE(output, QString("/home/robert/test.pdf")); 217 | } 218 | 219 | void TestMustache::testPartials() 220 | { 221 | QHash partials; 222 | partials["file-info"] = "{{name}} {{size}} {{type}}\n"; 223 | 224 | QString _template = "{{#files}}{{>file-info}}{{/files}}"; 225 | 226 | QVariantHash map; 227 | QVariantList fileList; 228 | 229 | QVariantHash file1; 230 | file1["name"] = "mustache.pdf"; 231 | file1["size"] = "200KB"; 232 | file1["type"] = "PDF Document"; 233 | 234 | QVariantHash file2; 235 | file2["name"] = "cv.doc"; 236 | file2["size"] = "300KB"; 237 | file2["type"] = "Microsoft Word Document"; 238 | 239 | fileList << file1 << file2; 240 | map["files"] = fileList; 241 | 242 | Mustache::Renderer renderer; 243 | Mustache::PartialMap partialMap(partials); 244 | Mustache::QtVariantContext context(map, &partialMap); 245 | QString output = renderer.render(_template, &context); 246 | 247 | QCOMPARE(output, 248 | QString("mustache.pdf 200KB PDF Document\n" 249 | "cv.doc 300KB Microsoft Word Document\n")); 250 | } 251 | 252 | void TestMustache::testSetDelimiters() 253 | { 254 | // test changing the markers within a template 255 | QVariantHash map; 256 | map["name"] = "John Smith"; 257 | map["phone"] = "01234 567890"; 258 | 259 | QString _template = 260 | "{{=<% %>=}}" 261 | "<%name%>{{ }}<%phone%>" 262 | "<%={{ }}=%>" 263 | " {{name}}<% %>{{phone}}"; 264 | 265 | QString expectedOutput = "John Smith{{ }}01234 567890 John Smith<% %>01234 567890"; 266 | 267 | Mustache::Renderer renderer; 268 | Mustache::QtVariantContext context(map); 269 | QString output = renderer.render(_template, &context); 270 | 271 | QCOMPARE(output, expectedOutput); 272 | 273 | // test changing the default markers 274 | renderer.setTagMarkers("%", "%"); 275 | output = renderer.render("%name%'s phone number is %phone%", &context); 276 | QCOMPARE(output, QString("John Smith's phone number is 01234 567890")); 277 | 278 | renderer.setTagMarkers("{{", "}}"); 279 | output = renderer.render("{{== ==}}", &context); 280 | QCOMPARE(renderer.error(), QString("Custom delimiters may not contain '='.")); 281 | } 282 | 283 | void TestMustache::testErrors() 284 | { 285 | QVariantHash map; 286 | map["name"] = "Jim Jones"; 287 | 288 | QHash partials; 289 | partials["buggy-partial"] = "--{{/one}}--"; 290 | 291 | QString _template = "{{name}}"; 292 | 293 | Mustache::Renderer renderer; 294 | Mustache::PartialMap partialMap(partials); 295 | Mustache::QtVariantContext context(map, &partialMap); 296 | QString output = renderer.render(_template, &context); 297 | 298 | QCOMPARE(output, QString("Jim Jones")); 299 | QCOMPARE(renderer.error(), QString()); 300 | QCOMPARE(renderer.errorPos(), -1); 301 | 302 | _template = "{{#one}} {{/two}}"; 303 | output = renderer.render(_template, &context); 304 | QCOMPARE(renderer.error(), QString("Tag start/end key mismatch")); 305 | QCOMPARE(renderer.errorPos(), 9); 306 | QCOMPARE(renderer.errorPartial(), QString()); 307 | 308 | _template = "Hello {{>buggy-partial}}"; 309 | output = renderer.render(_template, &context); 310 | QCOMPARE(renderer.error(), QString("Unexpected end tag")); 311 | QCOMPARE(renderer.errorPos(), 2); 312 | QCOMPARE(renderer.errorPartial(), QString("buggy-partial")); 313 | } 314 | 315 | void TestMustache::testPartialFile() 316 | { 317 | QString path = QCoreApplication::applicationDirPath(); 318 | 319 | QVariantHash map = contactInfo("Jim Smith", "jim.smith@gmail.com"); 320 | 321 | QString _template = "{{>partial}}"; 322 | 323 | Mustache::Renderer renderer; 324 | Mustache::PartialFileLoader partialLoader(path); 325 | Mustache::QtVariantContext context(map, &partialLoader); 326 | QString output = renderer.render(_template, &context); 327 | 328 | QCOMPARE(output, QString("Jim Smith -- jim.smith@gmail.com\n")); 329 | } 330 | 331 | void TestMustache::testEscaping() 332 | { 333 | QVariantHash map; 334 | map["escape"] = "foo"; 335 | map["unescape"] = "One & Two "quoted""; 336 | map["raw"] = "foo"; 337 | 338 | QString _template = "{{escape}} {{&unescape}} {{{raw}}}"; 339 | 340 | Mustache::Renderer renderer; 341 | Mustache::QtVariantContext context(map); 342 | QString output = renderer.render(_template, &context); 343 | 344 | QCOMPARE(output, QString("<b>foo</b> One & Two \"quoted\" foo")); 345 | } 346 | 347 | class CounterContext : public Mustache::QtVariantContext 348 | { 349 | public: 350 | int counter; 351 | 352 | CounterContext(const QVariantHash& map) 353 | : Mustache::QtVariantContext(map) 354 | , counter(0) 355 | {} 356 | 357 | virtual bool canEval(const QString& key) const { 358 | return key == "counter"; 359 | } 360 | 361 | virtual QString eval(const QString& key, const QString& _template, Mustache::Renderer* renderer) { 362 | if (key == "counter") { 363 | ++counter; 364 | } 365 | return renderer->render(_template, this); 366 | } 367 | 368 | virtual QString stringValue(const QString& key) const { 369 | if (key == "count") { 370 | return QString::number(counter); 371 | } else { 372 | return Mustache::QtVariantContext::stringValue(key); 373 | } 374 | } 375 | }; 376 | 377 | void TestMustache::testEval() 378 | { 379 | QVariantHash map; 380 | QVariantList list; 381 | list << contactInfo("Rob Knight", "robertknight@gmail.com"); 382 | list << contactInfo("Jim Smith", "jim.smith@smith.org"); 383 | map["list"] = list; 384 | 385 | QString _template = "{{#list}}{{#counter}}#{{count}} {{name}} {{email}}{{/counter}}\n{{/list}}"; 386 | 387 | Mustache::Renderer renderer; 388 | CounterContext context(map); 389 | QString output = renderer.render(_template, &context); 390 | QCOMPARE(output, QString("#1 Rob Knight robertknight@gmail.com\n" 391 | "#2 Jim Smith jim.smith@smith.org\n")); 392 | } 393 | 394 | void TestMustache::testHelpers() 395 | { 396 | QVariantHash args; 397 | args.insert("name", "Jim Smith"); 398 | args.insert("age", 42); 399 | 400 | QString output = Mustache::renderTemplate("Hello {{name}}, you are {{age}}", args); 401 | QCOMPARE(output, QString("Hello Jim Smith, you are 42")); 402 | } 403 | 404 | void TestMustache::testIncompleteTag() 405 | { 406 | QVariantHash args; 407 | args.insert("name", "Jim Smith"); 408 | 409 | QString output = Mustache::renderTemplate("Hello {{name}}, you are {", args); 410 | QCOMPARE(output, QString("Hello Jim Smith, you are {")); 411 | 412 | output = Mustache::renderTemplate("Hello {{name}}, you are {{", args); 413 | QCOMPARE(output, QString("Hello Jim Smith, you are {{")); 414 | 415 | output = Mustache::renderTemplate("Hello {{name}}, you are {{}", args); 416 | QCOMPARE(output, QString("Hello Jim Smith, you are {{}")); 417 | } 418 | 419 | void TestMustache::testIncompleteSection() 420 | { 421 | QVariantHash args; 422 | args.insert("list", QVariantList() << QVariantHash()); 423 | 424 | Mustache::Renderer renderer; 425 | Mustache::QtVariantContext context(args); 426 | QString output = renderer.render("{{#list}}", &context); 427 | QCOMPARE(output, QString()); 428 | QCOMPARE(renderer.error(), QString("No matching end tag found for section")); 429 | 430 | output = renderer.render("{{^list}}", &context); 431 | QCOMPARE(output, QString()); 432 | QCOMPARE(renderer.error(), QString("No matching end tag found for inverted section")); 433 | 434 | output = renderer.render("{{/list}}", &context); 435 | QCOMPARE(output, QString()); 436 | QCOMPARE(renderer.error(), QString("Unexpected end tag")); 437 | 438 | output = renderer.render("{{#list}}{{/foo}}", &context); 439 | QCOMPARE(output, QString()); 440 | QCOMPARE(renderer.error(), QString("Tag start/end key mismatch")); 441 | } 442 | 443 | static QString decorate(const QString& text, Mustache::Renderer* r, Mustache::Context* ctx) 444 | { 445 | return "~" + r->render(text, ctx) + "~"; 446 | } 447 | 448 | void TestMustache::testLambda() 449 | { 450 | QVariantHash args; 451 | args["text"] = "test"; 452 | args["fn"] = QVariant::fromValue(Mustache::QtVariantContext::fn_t(decorate)); 453 | QString output = Mustache::renderTemplate("{{#fn}}{{text}}{{/fn}}", args); 454 | QCOMPARE(output, QString("~test~")); 455 | } 456 | 457 | void TestMustache::testQStringListIteration() 458 | { 459 | QStringList list; 460 | list << "str1" << "str2" << "str3"; 461 | QVariantHash args; 462 | args["list"] = list; 463 | QString output = Mustache::renderTemplate("{{#list}}{{.}}{{/list}}", args); 464 | QCOMPARE(output, QString("str1str2str3")); 465 | } 466 | 467 | void TestMustache::testUnescapeHtml() 468 | { 469 | QVariantHash args; 470 | args["s"] = "<>&"&quot;"; 471 | QString output = Mustache::renderTemplate("{{&s}}", args); 472 | QCOMPARE(output, QString("<>&\""")); 473 | } 474 | 475 | #if QT_VERSION >= 0x050000 // JSON classes only in Qt 5+. 476 | 477 | void TestMustache::testConformance_data() 478 | { 479 | QTest::addColumn("data"); 480 | QTest::addColumn("template_"); 481 | QTest::addColumn >("partials"); 482 | QTest::addColumn("expected"); 483 | 484 | QDir specsDir = QDir("."); 485 | 486 | foreach (const QString &fileName, specsDir.entryList(QStringList() << "*.json")) { 487 | QFile file(specsDir.filePath(fileName)); 488 | QVERIFY2(file.open(QIODevice::ReadOnly), qPrintable(fileName + ": " + file.errorString())); 489 | 490 | QJsonDocument document = QJsonDocument::fromJson(file.readAll()); 491 | QJsonArray testCaseValues = document.object()["tests"].toArray(); 492 | 493 | for (const QJsonValue &testCaseValue: testCaseValues) { 494 | QJsonObject testCaseObject = testCaseValue.toObject(); 495 | 496 | QString name = fileName + " - " + testCaseObject["name"].toString(); 497 | QVariantMap data = testCaseObject["data"].toObject().toVariantMap(); 498 | QString template_ = testCaseObject["template"].toString(); 499 | QJsonObject partialsObject = testCaseObject["partials"].toObject(); 500 | PartialsHash partials; 501 | foreach (const QString &partialName, partialsObject.keys()) { 502 | partials.insert(partialName, partialsObject[partialName].toString()); 503 | } 504 | QString expected = testCaseObject["expected"].toString(); 505 | 506 | QTest::newRow(qPrintable(name)) << data << template_ << partials << expected; 507 | } 508 | } 509 | } 510 | 511 | /* 512 | * This test will run once for each test case defined in version 1.1.2 of the 513 | * Mustache specification [1]. 514 | * 515 | * [1] https://github.com/mustache/spec/tree/v1.1.2/specs 516 | */ 517 | void TestMustache::testConformance() 518 | { 519 | QFETCH(QVariantMap, data); 520 | QFETCH(QString, template_); 521 | QFETCH(PartialsHash, partials); 522 | QFETCH(QString, expected); 523 | 524 | Mustache::Renderer renderer; 525 | Mustache::PartialMap partialsMap(partials); 526 | Mustache::QtVariantContext context(data, &partialsMap); 527 | 528 | QString output = renderer.render(template_, &context); 529 | 530 | QCOMPARE(output, expected); 531 | } 532 | 533 | #endif // QT_VERSION >= 0x050000 534 | 535 | // Create a QCoreApplication for the test. In Qt 5 this can be 536 | // done with QTEST_GUILESS_MAIN(). 537 | int main(int argc, char** argv) 538 | { 539 | QCoreApplication app(argc, argv); 540 | TestMustache testObject; 541 | return QTest::qExec(&testObject, argc, argv); 542 | } 543 | -------------------------------------------------------------------------------- /tests/test_mustache.h: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2012, Robert Knight 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | 10 | Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | */ 14 | 15 | #pragma once 16 | 17 | #include "mustache.h" 18 | 19 | #include 20 | 21 | class TestMustache : public QObject 22 | { 23 | Q_OBJECT 24 | 25 | private Q_SLOTS: 26 | void testContextLookup(); 27 | void testErrors(); 28 | void testPartialFile(); 29 | void testPartials(); 30 | void testSections(); 31 | void testSectionQString(); 32 | void testFalsiness(); 33 | void testFloatValues(); 34 | void testSetDelimiters(); 35 | void testValues(); 36 | void testEscaping(); 37 | void testEval(); 38 | void testHelpers(); 39 | void testIncompleteTag(); 40 | void testIncompleteSection(); 41 | void testLambda(); 42 | void testQStringListIteration(); 43 | void testUnescapeHtml(); 44 | #if QT_VERSION >= 0x050000 45 | void testConformance(); 46 | void testConformance_data(); 47 | #endif // QT_VERSION >= 0x050000 48 | }; 49 | 50 | --------------------------------------------------------------------------------