commit 1151ec0a9ed6dac8243a64b13b72afdcb2613958 Author: Tyler Akins Date: Fri Jan 23 17:43:08 2015 +0000 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..427043b --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +*.swp +tests/*.diff diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..93622a5 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,7 @@ +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +Except as contained in this notice, the name(s) of the above copyright holders shall not be used in advertising or otherwise to promote the sale, use or other dealings in this Software without prior written authorization. + +The end-user documentation included with the redistribution, if any, must include the following acknowledgment: "This product includes software developed by contributors", in the same place and form as other third-party acknowledgments. Alternately, this acknowledgment may appear in the software itself, in the same form and location as other such third-party acknowledgments. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d0822bc --- /dev/null +++ b/README.md @@ -0,0 +1,55 @@ +Mo - Mustache Templates in Bash +=============================== + +[Mustache] templates are simple, logic-less templates. Because of their simplicity, they are able to be ported to many languages. The syntax is quite simple. + + Hello, {{NAME}}. + + I hope your {{TIME_PERIOD}} was fun. + +Let's try using this with some data in bash. Save those lines to `trip.txt` and run a command like this: + + NAME=Tyler TIME_PERIOD=weekend ./mo weekend-trip.txt + +Your result? + + Hello, Tyler. + + I hope your weekend was fun. + +This bash version supports conditionals, functions (both as filters and as values), as well as indexed arrays (for iteration). You are able to leverage these additional features by adding more information into the environment. It is easiest to do this when you source `mo`. See the [demo scripts](demo/) for further examples. + + +Requirements +------------ + +* Bash 3.x (the aim is to make it work on Macs) +* The "coreutils" package (`basename` and `cat`) +* ... that's it. Why? Because bash **can**! + + +Concessions +----------- + +I admit that implementing everything in bash just doesn't make a lot of sense. For example, the following things just don't work. + +* Bash does not support nested structures like fancy objects. The best you can do are arrays. I'm not able to add super complex structures to bash - it's just a shell after all! +* There's no "top level" object that `{.}` refers to in `mo`. In other languages you can say the data for the template is a string. That's ok because using `{.}` when processing a top level scope is rare. Using `{.}` works great when iterating over an array. +* HTML encoding is not built into `mo`. The `{{{...}}}` and `{{...}}` tags both work. `echo '{{TEST}}' | TEST='' mo` will give you "``" instead of "`>b<`". +* You must make sure the data is in the environment when `mo` runs. The easiest way to do that is to source `mo` in your shell script after setting up lots of other environment variables / functions. +* Associative arrays are not addressable via their index. You can't use `{{VARIABLE_NAME.INDEX_NAME}}` and expect it to work. Associative arrays aren't supported in Bash 3. + + +Developing +---------- + +Check out the code and hack away. Please add tests to show off bugs before fixing them. New functionality should also be covered by a test. + + +License +------- + +This program is licensed under an MIT license with an additional non-advertising clause. See [LICENSE.md](LICENSE.md) for the full text. + + +[Mustache]: https://mustache.github.io/ diff --git a/demo/important-file b/demo/important-file new file mode 100755 index 0000000..6f3d94f --- /dev/null +++ b/demo/important-file @@ -0,0 +1,23 @@ +#!/bin/bash + +date-string() { date; } +wrapper() { echo -n "*** $1 ***"; } + +IP=127.0.0.1 +ALLOWED_HOSTS=( 192.168.0.1 192.168.0.2 192.168.0.3 ) + +cat < /dev/null 2>&1 + elif mustache-is-array "$MUSTACHE_TAG"; then + eval 'mustache-loop MUSTACHE_CONTENT "$MUSTACHE_CONTENT" "$MUSTACHE_TAG" "${'"$MUSTACHE_TAG"'[@]}"' + else + mustache-parse MUSTACHE_CONTENT "$MUSTACHE_CONTENT" "$MUSTACHE_TAG" "$(mustache-show "$MUSTACHE_TAG")" + fi + else + # Do not show + mustache-parse MUSTACHE_CONTENT "$MUSTACHE_CONTENT" "$MUSTACHE_TAG" > /dev/null + fi + ;; + + '>'*) + # Load partial - get name of file relative to cwd + mustache-trim MUSTACHE_TAG "${MUSTACHE_TAG:1}" + + # Execute in subshell to preserve current cwd + ( + cd "$(dirname "$MUSTACHE_TAG")" + MUSTACHE_TAG=$(basename "$MUSTACHE_TAG") + MUSTACHE_TAG=$(cat "$MUSTACHE_TAG" 2>/dev/null) + mustache-parse MUSTACHE_TAG "$MUSTACHE_TAG" "" "$MUSTACHE_CURRENT" + echo -n $MUSTACHE_TAG + ) + ;; + + '/'*) + # Closing tag - If we hit MUSTACHE_END_TAG, we're done. + mustache-trim MUSTACHE_TAG "${MUSTACHE_TAG:1}" + + if [[ "$MUSTACHE_TAG" == "$MUSTACHE_END_TAG" ]]; then + # Tag hit - done + local "$1" && mustache-indirect "$1" "$MUSTACHE_CONTENT" + return 0 + fi + + # If the tag does not match, we ignore this tag + ;; + + '^'*) + # Display section if named thing does not exist + mustache-trim MUSTACHE_TAG "${MUSTACHE_TAG:1}" + + if mustache-test "$MUSTACHE_TAG"; then + # Do not show + mustache-parse MUSTACHE_CONTENT "$MUSTACHE_CONTENT" "$MUSTACHE_TAG" > /dev/null 2>&1 + else + # Show + mustache-parse MUSTACHE_CONTENT "$MUSTACHE_CONTENT" "$MUSTACHE_TAG" + fi + ;; + + '!'*) + # Comment - ignore the tag entirely + ;; + + .) + # Current content (environment variable or function) + echo -n "$MUSTACHE_CURRENT" + ;; + + '{'*) + # Unescaped - split on }}} not }} + MUSTACHE_CONTENT="${MUSTACHE_TAG:1}"'}}'"$MUSTACHE_CONTENT" + mustache-split MUSTACHE_CONTENT "$MUSTACHE_CONTENT" '}}}' + mustache-trim MUSTACHE_TAG "${MUSTACHE_CONTENT[0]}" + MUSTACHE_CONTENT="${MUSTACHE_CONTENT[1]}" + + # Now show the value + mustache-show "$MUSTACHE_TAG" + ;; + + *) + # Normal environment variable or function call + mustache-show "$MUSTACHE_TAG" + ;; + esac + + mustache-split MUSTACHE_CONTENT "$MUSTACHE_CONTENT" '{{' '}}' + done + + echo -n "${MUSTACHE_CONTENT[0]}" + local "$1" && mustache-indirect "$1" "" +} + + +# Show an environment variable or the output of a function. +# +# Parameters: +# $1: Name of environment variable or function +mustache-show() { + if mustache-is-function "$1"; then + $1 + else + echo -n "${!1}" + fi +} + + +# Returns 0 (success) if the named thing is a function or if it is a non-empty +# environment variable. +# +# Do not use unprefixed variables here if possible as this needs to check +# if any name exists in the environment +# +# Parameters: +# $1: Name of environment variable or function +# +# Return code: +# 0 if the name is not empty, 1 otherwise +mustache-test() { + # Test for functions + mustache-is-function "$1" && return 0 + + if mustache-is-array "$1"; then + # Arrays must have at least 1 element + eval '[[ ${#'"$1"'} -gt 0 ]]' && return 0 + else + # Environment variables must not be empty + [[ ! -z "${!1}" ]] && return 0 + fi + + return 1 +} + + +# Determine if a given environment variable exists and if it is an array. +# +# Parameters: +# $1: Name of environment variable +# +# Return code: +# 0 if the name is not empty, 1 otherwise +mustache-is-array() { + local MUSTACHE_TEST + + MUSTACHE_TEST=$(declare -p "$1" 2>/dev/null) || return 1 + [[ "${MUSTACHE_TEST:0:10}" == "declare -a" ]] || return 1 +} + + +# Trim leading and trailing whitespace from a string +# +# Parameters: +# $1: Name of variable to store trimmed string +# $2: The string +mustache-trim() { + local CR CURRENT MODIFIED NEEDLE NL TAB SPACE VAR + + CR="$'\r'" + NL="$'\n'" + TAB="$'\t'" + SPACE=" " + CURRENT="$2" + LAST="" + + while [[ "$CURRENT" != "$LAST" ]]; do + LAST="$CURRENT" + + for VAR in CR NL TAB SPACE; do + NEEDLE="${!VAR}" + CURRENT="${CURRENT/#$NEEDLE}" + CURRENT="${CURRENT/%$NEEDLE}" + done + done + + local "$1" && mustache-indirect "$1" "$CURRENT" +} + + +# Split a larger string into an array +# +# Parameters: +# $1: Destination variable +# $2: String to split +# $3: Starting delimeter +# $4: Ending delimeter (optional) +mustache-split() { + local POS RESULT + + RESULT=( "$2" ) + mustache-find-string POS "${RESULT[0]}" "$3" + + if [[ $POS -ne -1 ]]; then + # The first delimeter was found + RESULT[1]="${RESULT[0]:$POS + ${#3}}" + RESULT[0]="${RESULT[0]:0:$POS}" + + if [[ ! -z "$4" ]]; then + mustache-find-string POS "${RESULT[1]}" "$4" + + if [[ $POS -ne -1 ]]; then + # The second delimeter was found + RESULT[2]="${RESULT[1]:$POS + ${#4}}" + RESULT[1]="${RESULT[1]:0:$POS}" + fi + fi + fi + + local "$1" && mustache-indirect-array "$1" "${RESULT[@]}" +} + +# Find the first index of a substring +# +# Parameters: +# $1: Destination variable +# $2: Haystack +# $3: Needle +mustache-find-string() { + local POS STRING + + STRING="${2%%$3*}" + [[ "$STRING" == "$2" ]] && POS=-1 || POS=${#STRING} + local "$1" && mustache-indirect "$1" $POS +} + + +# Send a variable up to caller of a function +# +# Parameters: +# $1: Variable name +# $2: Value +mustache-indirect() { + unset -v "$1" + printf -v "$1" '%s' "$2" +} + + +# Send an array up to caller of a function +# +# Parameters: +# $1: Variable name +# $2-*: Array elements +mustache-indirect-array() { + unset -v "$1" + eval $1=\(\"\${@:2}\"\) +} + + +# Return the content to parse. Can be a list of partials for files or +# the content from stdin. +# +# Parameters: +# $1: Variable name to assign this content back as +# $2-*: File names (optional) +mustache-get-content() { + local CONTENT FILENAME TARGET + + TARGET="$1" + shift + if [[ ${#@} -gt 0 ]]; then + CONTENT="" + + for FILENAME in ${1+"$@"}; do + CONTENT="$CONTENT"'{{>'"$FILENAME"'}}' + done + else + # Workaround to avoid newlines being gobbled by the subshell + CONTENT="$(cat -; echo .)" + CONTENT=${CONTENT:0: -1} + fi + + local "$TARGET" && mustache-indirect "$TARGET" "$CONTENT" +} + + +# Save the list of functions as an array +MUSTACHE_FUNCTIONS=$(declare -F) +MUSTACHE_FUNCTIONS=( ${MUSTACHE_FUNCTIONS//declare -f /} ) +mustache-get-content MUSTACHE_CONTENT ${1+"$@"} +mustache-parse MUSTACHE_CONTENT "$MUSTACHE_CONTENT" diff --git a/run-tests b/run-tests new file mode 100755 index 0000000..5879324 --- /dev/null +++ b/run-tests @@ -0,0 +1,30 @@ +#!/bin/bash + +cd "$(dirname $0)" + +PASS=0 +FAIL=0 + +for TEST in tests/*.expected; do + BASE="${TEST%.expected}" + + echo -n "$BASE ... " + ( + . "${BASE}.env" + . ./mo "${BASE}.template" + ) | diff -wU5 - "${TEST}" > "${BASE}.diff" + + if [[ $? -ne 0 ]]; then + echo "FAIL" + FAIL=$(( $FAIL + 1 )) + else + echo "ok" + PASS=$(( $PASS + 1 )) + rm "${BASE}.diff" + fi +done + +echo "" +echo "Pass: $PASS" +echo "Fail: $FAIL" +[[ $FAIL -gt 0 ]] && exit 1 diff --git a/tests/array.env b/tests/array.env new file mode 100644 index 0000000..e8a0a4e --- /dev/null +++ b/tests/array.env @@ -0,0 +1 @@ +repo=( "resque" "hub" "rip" ) diff --git a/tests/array.expected b/tests/array.expected new file mode 100644 index 0000000..aa1dee6 --- /dev/null +++ b/tests/array.expected @@ -0,0 +1,6 @@ + + resque + + hub + + rip diff --git a/tests/array.template b/tests/array.template new file mode 100644 index 0000000..4c61e9c --- /dev/null +++ b/tests/array.template @@ -0,0 +1,3 @@ +{{#repo}} + {{.}} +{{/repo}} diff --git a/tests/comment-newline.env b/tests/comment-newline.env new file mode 100644 index 0000000..e69de29 diff --git a/tests/comment-newline.expected b/tests/comment-newline.expected new file mode 100644 index 0000000..e7c3be5 --- /dev/null +++ b/tests/comment-newline.expected @@ -0,0 +1 @@ +

Today.

diff --git a/tests/comment-newline.template b/tests/comment-newline.template new file mode 100644 index 0000000..670ab1d --- /dev/null +++ b/tests/comment-newline.template @@ -0,0 +1,4 @@ +

Today{{! ignore me +and this can +run through multiple +lines}}.

diff --git a/tests/comment.env b/tests/comment.env new file mode 100644 index 0000000..e69de29 diff --git a/tests/comment.expected b/tests/comment.expected new file mode 100644 index 0000000..e7c3be5 --- /dev/null +++ b/tests/comment.expected @@ -0,0 +1 @@ +

Today.

diff --git a/tests/comment.template b/tests/comment.template new file mode 100644 index 0000000..9f7a242 --- /dev/null +++ b/tests/comment.template @@ -0,0 +1 @@ +

Today{{! ignore me }}.

diff --git a/tests/false-list.env b/tests/false-list.env new file mode 100644 index 0000000..eef0ddd --- /dev/null +++ b/tests/false-list.env @@ -0,0 +1 @@ +person="" diff --git a/tests/false-list.expected b/tests/false-list.expected new file mode 100644 index 0000000..4790a9e --- /dev/null +++ b/tests/false-list.expected @@ -0,0 +1 @@ +Shown. diff --git a/tests/false-list.template b/tests/false-list.template new file mode 100644 index 0000000..d67c751 --- /dev/null +++ b/tests/false-list.template @@ -0,0 +1,4 @@ +Shown. +{{#person}} + Never shown! +{{/person}} diff --git a/tests/function.env b/tests/function.env new file mode 100644 index 0000000..64b0225 --- /dev/null +++ b/tests/function.env @@ -0,0 +1,4 @@ +name=Willy +wrapped() { + echo "$1"; +} diff --git a/tests/function.expected b/tests/function.expected new file mode 100644 index 0000000..67e44a3 --- /dev/null +++ b/tests/function.expected @@ -0,0 +1,2 @@ + + Willy is awesome. diff --git a/tests/function.template b/tests/function.template new file mode 100644 index 0000000..b069eb7 --- /dev/null +++ b/tests/function.template @@ -0,0 +1,3 @@ +{{#wrapped}} + {{name}} is awesome. +{{/wrapped}} diff --git a/tests/inverted.env b/tests/inverted.env new file mode 100644 index 0000000..5a562a4 --- /dev/null +++ b/tests/inverted.env @@ -0,0 +1 @@ +repo=() diff --git a/tests/inverted.expected b/tests/inverted.expected new file mode 100644 index 0000000..a3df620 --- /dev/null +++ b/tests/inverted.expected @@ -0,0 +1,3 @@ + + + No repos :( diff --git a/tests/inverted.template b/tests/inverted.template new file mode 100644 index 0000000..ea6ebe2 --- /dev/null +++ b/tests/inverted.template @@ -0,0 +1,6 @@ +{{#repo}} + {{.}} +{{/repo}} +{{^repo}} + No repos :( +{{/repo}} diff --git a/tests/miss.env b/tests/miss.env new file mode 100644 index 0000000..dfba6af --- /dev/null +++ b/tests/miss.env @@ -0,0 +1,2 @@ +name="Chris" +company="GitHub" diff --git a/tests/miss.expected b/tests/miss.expected new file mode 100644 index 0000000..eae407e --- /dev/null +++ b/tests/miss.expected @@ -0,0 +1,4 @@ +* Chris +* +* GitHub +* GitHub diff --git a/tests/miss.template b/tests/miss.template new file mode 100644 index 0000000..d278abe --- /dev/null +++ b/tests/miss.template @@ -0,0 +1,4 @@ +* {{name}} +* {{age}} +* {{company}} +* {{{company}}} diff --git a/tests/mush.env b/tests/mush.env new file mode 100644 index 0000000..dcd1f87 --- /dev/null +++ b/tests/mush.env @@ -0,0 +1,6 @@ +USER=jwerle +GENDER=male +THING=apple +COLOR=red +PERSON=tobi +ADJECTIVE=cool diff --git a/tests/mush.expected b/tests/mush.expected new file mode 100644 index 0000000..3cc885a --- /dev/null +++ b/tests/mush.expected @@ -0,0 +1,7 @@ + + +jwerle is male +apple is red +tobi is cool +jwerle is friends with tobi + diff --git a/tests/mush.template b/tests/mush.template new file mode 100644 index 0000000..2198d87 --- /dev/null +++ b/tests/mush.template @@ -0,0 +1,7 @@ +{{! this is a comment }} + +{{USER}} is {{GENDER}} +{{THING}} is {{COLOR}} +{{PERSON}} is {{ADJECTIVE}} +{{USER}} is friends with {{PERSON}} +{{var}} {{value}} diff --git a/tests/non-false.env b/tests/non-false.env new file mode 100644 index 0000000..83105eb --- /dev/null +++ b/tests/non-false.env @@ -0,0 +1 @@ +person=Jon diff --git a/tests/non-false.expected b/tests/non-false.expected new file mode 100644 index 0000000..23cfca4 --- /dev/null +++ b/tests/non-false.expected @@ -0,0 +1,2 @@ + + Hi Jon! diff --git a/tests/non-false.template b/tests/non-false.template new file mode 100644 index 0000000..fe0e022 --- /dev/null +++ b/tests/non-false.template @@ -0,0 +1,3 @@ +{{#person}} + Hi {{.}}! +{{/person}} diff --git a/tests/partial.env b/tests/partial.env new file mode 100644 index 0000000..6ada993 --- /dev/null +++ b/tests/partial.env @@ -0,0 +1 @@ +names=( "Tyler" ) diff --git a/tests/partial.expected b/tests/partial.expected new file mode 100644 index 0000000..cc95c9b --- /dev/null +++ b/tests/partial.expected @@ -0,0 +1,3 @@ +

Names

+ + Tyler diff --git a/tests/partial.partial b/tests/partial.partial new file mode 100644 index 0000000..e25759d --- /dev/null +++ b/tests/partial.partial @@ -0,0 +1 @@ +{{.}} diff --git a/tests/partial.template b/tests/partial.template new file mode 100644 index 0000000..af24a02 --- /dev/null +++ b/tests/partial.template @@ -0,0 +1,4 @@ +

Names

+{{#names}} + {{> partial.partial}} +{{/names}} diff --git a/tests/typical.env b/tests/typical.env new file mode 100644 index 0000000..7d8ccf8 --- /dev/null +++ b/tests/typical.env @@ -0,0 +1,4 @@ +NAME="Chris" +VALUE=10000 +TAXED_VALUE=6000 +IN_CA=true diff --git a/tests/typical.expected b/tests/typical.expected new file mode 100644 index 0000000..9eeff21 --- /dev/null +++ b/tests/typical.expected @@ -0,0 +1,4 @@ +Hello Chris +You have just won 10000 dollars! + +Well, 6000 dollars, after taxes. diff --git a/tests/typical.template b/tests/typical.template new file mode 100644 index 0000000..d91c244 --- /dev/null +++ b/tests/typical.template @@ -0,0 +1,5 @@ +Hello {{NAME}} +You have just won {{VALUE}} dollars! +{{#IN_CA}} +Well, {{TAXED_VALUE}} dollars, after taxes. +{{/IN_CA}