diff --git a/Dockerfile b/Dockerfile
index 64adca7..bc5bf9c 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,4 +1,4 @@
-FROM ubuntu:20.04
+FROM ubuntu:24.04
ENV PANDOC_VERSION=2.17.0.1
diff --git a/README.md b/README.md
index b61fef9..d90a7c8 100644
--- a/README.md
+++ b/README.md
@@ -18,19 +18,19 @@ Check out the [docs] or [bic-example] repository.
Build (local develop):
```bash
-docker build . -t bic:local
+docker build --platform linux/amd64 . -t bic:local
```
Run (local develop) with [bic-example]:
```bash
-docker run --rm -it -v $PWD/../bic-example:/src -v $PWD:/app --entrypoint bash bic:local
+docker run --platform linux/amd64 --rm -it -v $PWD/../bic-example:/src -v $PWD:/app --entrypoint bash bic:local
```
Run (just build) with [bic-example]:
```bash
-docker run --rm -v $PWD/../bic-example:/src bic:local
+docker run --platform linux/amd64 --rm -v $PWD/../bic-example:/src bic:local
```
Run using [nix flakes]
@@ -42,7 +42,7 @@ nix shell github:Pinjasaur/bic --command bic $PWD/../bic-example
Local server (ran in [bic-example]):
```bash
-browser-sync --watch --no-notify --extensions html build
+npx -y browser-sync --watch --no-notify --extensions html build
```
Run test suite (uses [BATS]):
diff --git a/bic b/bic
index a832b88..dbf2b00 100755
--- a/bic
+++ b/bic
@@ -25,6 +25,8 @@ source "${__dir}"/lib/mo
PANDOC_EXTS="+autolink_bare_uris+gfm_auto_identifiers+task_lists"
PANDOC_ARGS=(-f markdown"${PANDOC_EXTS:-}" -t html5 --columns 1000 --no-highlight --email-obfuscation=none)
+TAG_PREFIX="tags:"
+
# Print out usage information
usage() {
cat </\>/g; s/"/\"/g; s/'"'"'/\'/g'
}
+__has_tags() {
+ local tagline
+ tagline="$(head -n 2 < "${1}" | tail -n 1)"
+ [[ "$tagline" == "$TAG_PREFIX"* ]] && return 0
+ return 1
+}
+
mk_title() {
local title
title="$(head -n 1 < "${1}")"
@@ -69,7 +78,21 @@ mk_title() {
}
mk_body() {
- pandoc "${PANDOC_ARGS[@]}" <(tail -n +2 "${1}")
+ if __has_tags "${1}"; then
+ pandoc "${PANDOC_ARGS[@]}" <(tail -n +3 "${1}")
+ else
+ pandoc "${PANDOC_ARGS[@]}" <(tail -n +2 "${1}")
+ fi
+}
+
+mk_tags() {
+ if __has_tags "${1}"; then
+ local tagline split
+ tagline="$(head -n 2 < "${1}" | tail -n 1)"
+ split="$(echo "${tagline#"$TAG_PREFIX"}" | tr -d '[:space:]')"
+ IFS=',' read -r -a tags <<< "$split"
+ echo "${tags[@]}"
+ fi
}
mk_slug() {
@@ -127,10 +150,21 @@ build_posts() {
ALL_POSTS=()
for _post in "${SRC_DIR}"/posts/*.md; do
log "Building post: ${_post}"
+ [[ "${_post}" =~ \| ]] && fatal "Filename cannot contain a pipe literal (used internally for delimitting tagged entries)"
ALL_POSTS+=("${_post}")
- local filename filename_sans_id
+ local filename filename_sans_id tags=()
filename="$(basename "${_post}")"
filename_sans_id="$(echo "${filename}" | sed -E 's/^[0-9]+-//')"
+ for _tag in $(mk_tags "${_post}"); do
+ local slug
+ slug="$(mk_slug "${_tag}")"
+ tags+=("${slug}")
+ if [[ -n "${ALL_TAGS["${slug}"]:-}" ]]; then
+ ALL_TAGS["${slug}"]+="|${_post}"
+ else
+ ALL_TAGS["${slug}"]="${_post}"
+ fi
+ done
(
post=true
@@ -139,7 +173,7 @@ build_posts() {
date="$(mk_date "${_post}")"
slug="$(mk_slug "${filename_sans_id%.md}")"
id="$(mk_id "${filename}" "${filename_sans_id}")"
- export title body date slug id post
+ export title body date slug id post tags
mo \
"${SRC_DIR}"/entry.html \
> "${DEST_DIR}/${slug}.html"
@@ -154,9 +188,14 @@ build_drafts() {
for _draft in "${SRC_DIR}"/drafts/*.md; do
log "Building draft: ${_draft}"
ALL_DRAFTS+=("${_draft}")
- local filename filename_sans_id
+ local filename filename_sans_id tags=()
filename="$(basename "${_draft}")"
filename_sans_id="$(echo "${filename}" | sed -E 's/^[0-9]+-//')"
+ for _tag in $(mk_tags "${_draft}"); do
+ local slug
+ slug="$(mk_slug "${_tag}")"
+ tags+=("${slug}")
+ done
mkdir -p "${DEST_DIR}"/drafts
(
@@ -166,7 +205,7 @@ build_drafts() {
date="$(mk_date "${_draft}")"
slug="$(mk_slug "${filename_sans_id%.md}")"
id="$(mk_id "${filename}" "${filename_sans_id}")"
- export title body date slug id draft
+ export title body date slug id draft tags
mo \
"${SRC_DIR}"/entry.html \
> "${DEST_DIR}/drafts/${slug}.html"
@@ -175,6 +214,55 @@ build_drafts() {
unset _draft
}
+build_tags() {
+ [[ ! -f "${SRC_DIR}"/tags.html || ! -f "${SRC_DIR}"/tag.html || ! -f "${SRC_DIR}"/__tag.html ]] && return
+
+ declare -A all_tags
+ for tag in "${!ALL_TAGS[@]}"; do
+ IFS='|' read -r -a posts <<< "${ALL_TAGS["${tag}"]}"
+ all_tags["${tag}"]="${#posts[@]}"
+ build_tag "${tag}"
+ done
+
+ log "Building tags.html"
+ (
+ title="All tags"
+ export title all_tags
+ mo \
+ "${SRC_DIR}"/tags.html \
+ > "${DEST_DIR}"/tags.html
+ )
+}
+
+build_tag() {
+ log "Building tags/${1}.html"
+
+ local __tag=""
+ local _posts
+ local tag="${1}"
+ IFS='|' read -r -a posts <<< "${ALL_TAGS["${tag}"]}"
+ for post in "${posts[@]}"; do
+ local filename filename_sans_id title date slug id
+ filename="$(basename "${post}")"
+ filename_sans_id="$(echo "${filename}" | sed -E 's/^[0-9]+-//')"
+ title="$(mk_title "${post}")"
+ date="$(mk_date "${post}")"
+ slug="$(mk_slug "${filename_sans_id%.md}")"
+ id="$(mk_id "${filename}" "${filename_sans_id}")"
+ __tag+="$(mo "${SRC_DIR}"/__tag.html)"
+ __tag+=$'\n'
+ done
+
+ mkdir -p "${DEST_DIR}"/tags
+ (
+ title="All posts tagged ${tag}"
+ export __tag title tag
+ mo \
+ "${SRC_DIR}"/tag.html \
+ > "${DEST_DIR}/tags/${tag}.html"
+ )
+}
+
build_index() {
log "Building index.html"
@@ -201,7 +289,7 @@ build_index() {
}
build_sitemap() {
- [[ ! -f "${SRC_DIR}/sitemap.xml" ]] && return
+ [[ ! -f "${SRC_DIR}"/sitemap.xml ]] && return
log "Building sitemap.xml"
local slugs=()
@@ -222,6 +310,13 @@ build_sitemap() {
slugs+=("$(html_escape "${slug}")")
done
+ if [[ -f "${SRC_DIR}"/tags.html && -f "${SRC_DIR}"/tag.html && -f "${SRC_DIR}"/__tag.html ]]; then
+ slugs+=("$(html_escape "tags")")
+ for tag in "${!ALL_TAGS[@]}"; do
+ slugs+=("$(html_escape "tags/${tag}")")
+ done
+ fi
+
(
export slugs
mo \
@@ -243,7 +338,7 @@ build_robots() {
}
build_feed() {
- [[ ! -f "${SRC_DIR}/feed.rss" || ! -f "${SRC_DIR}"/__feed.rss ]] && return
+ [[ ! -f "${SRC_DIR}"/feed.rss || ! -f "${SRC_DIR}"/__feed.rss ]] && return
log "Building feed.rss"
local __feed=""
@@ -286,8 +381,9 @@ build() {
# File system: what's missing and do we need to bail?
[[ -d "${SRC_DIR}"/pages && -f "${SRC_DIR}"/page.html ]] || warn "pages/ + page.html for pages"
[[ -d "${SRC_DIR}"/posts && -f "${SRC_DIR}"/entry.html ]] || warn "posts/ + entry.html for posts"
- [[ -f "${SRC_DIR}"/index.html && -f "${SRC_DIR}"/__index.html ]] || fatal "index.html (and __index.html) required"
- [[ -f "${SRC_DIR}"/feed.rss && -f "${SRC_DIR}"/__feed.rss ]] || warn "feed.rss (and __feed.rss) for an RSS feed"
+ [[ -f "${SRC_DIR}"/index.html && -f "${SRC_DIR}"/__index.html ]] || fatal "index.html (and __index.html) required"
+ [[ -f "${SRC_DIR}"/feed.rss && -f "${SRC_DIR}"/__feed.rss ]] || warn "feed.rss (and __feed.rss) for an RSS feed"
+ [[ -f "${SRC_DIR}"/tags.html && -f "${SRC_DIR}"/tag.html && -f "${SRC_DIR}"/__tag.html ]] || warn "tags.html, tag.html (and __tag.html) for tagging entries"
[[ -f "${SRC_DIR}"/sitemap.xml ]] || warn "sitemap.xml for a sitemap for search engines"
[[ -f "${SRC_DIR}"/robots.txt ]] || warn "robots.txt for web crawlers"
@@ -301,9 +397,13 @@ build() {
[[ -d "${DEST_DIR}" ]] && rm -rf "${DEST_DIR}"
mkdir -p "${DEST_DIR}" && log "Created build directory: ${DEST_DIR}"
+ # Need to declare this slightly more globally otherwise can't share it across functions 🤷
+ declare -A ALL_TAGS
+
build_pages # side effect(s): $ALL_PAGES
- build_posts # side effect(s): $ALL_POSTS
+ build_posts # side effect(s): $ALL_POSTS, $ALL_TAGS
build_drafts # side effect(s): $ALL_DRAFTS
+ build_tags # uses $ALL_TAGS
build_index # uses $ALL_POSTS
build_sitemap # uses $ALL_PAGES, $ALL_POSTS
build_robots
diff --git a/docs/index.md b/docs/index.md
index 621d567..deb98c3 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -23,6 +23,7 @@ You get the (opinionated) basics of a static site/blog (read: opinionated):
- robots.txt
- sitemap.xml
- RSS feed
+- tags for organizing entries
For reproducible builds, I would recommend using `bic` with Docker: `ghcr.io/pinjasaur/bic:latest`
@@ -44,7 +45,6 @@ nix run github:Pinjasaur/bic --command bic .
to run `bic` in the current directory and spit out a `build` directory with your
generated site.
-
## Opinionated?
@@ -52,21 +52,23 @@ generated site.
- Pages exist in `pages/*.md`. Not nested.
- Posts & drafts exist within `posts/*.md` and `drafts/*.md`, respectively.
- - _Ordering_ is determined by a number prefix e.g., `999-post.md`
- for the first post, `998-tacocat.md` for the second, et cetera. I would
- recommend 3 or 4 digits for the Future Proof™.
- - This lets the file `mtime` be used for the author's discretion. However,
- Git [doesn't record `mtime`][mtime], so I would treat it as the "last
- modified" date.
- - The title is derived from the _first line_ which _must_ begin with `#` to
- signify the top-level heading.
+ - _Ordering_ is determined by a number prefix e.g., `999-post.md`
+ for the first post, `998-tacocat.md` for the second, et cetera. I would
+ recommend 3 or 4 digits for the Future Proof™.
+ - This lets the file `mtime` be used for the author's discretion. However,
+ Git [doesn't record `mtime`][mtime], so I would treat it as the "last
+ modified" date.
+ - The title is derived from the _first line_ which MUST begin with `#` to
+ signify the top-level heading.
+ - Entries can be organized via tags, which MUST be defined _immediately_
+ below the title using syntax such like: `tags: foo, bar-baz`.
- Slugs are bare e.g., `/my-cool-post` _not_ `/posts/2021/my-cool-post.html`.
## Structure
For a fully-featured example, view the demo source code:
-```
+```plaintext
$ tree -F --dirsfirst
.
├── drafts/
@@ -133,8 +135,9 @@ Some specific keys used within entries (posts or drafts) and pages:
- `slug`, to be used in URL (does _not_ contain the `.html` file extension)
- `title`, taken from first line of file `# ...`
- `date`, literally the `mtime` of the file
-- `id`, the number prefix for an entry encoded with [Hashids]
+- `id`, the number prefix for an _entry_ encoded with [Hashids]
- `body`, converted Markdown to HTML contents (sans title)
+- `tags`, list of all tags for the _entry_
Drafts will have a `draft` key set. Likewise, posts will have a `post` key set.
@@ -143,10 +146,13 @@ Each entry in `posts/*.md` or `drafts/*.md` is rendered against an `entry.html`.
Each page in `pages/*.md` is rendered against a `page.html`.
{% raw %}
-`index.html` and `feed.rss` both use a [double-underscore-prefixed] template
-partial of the same name e.g., `{{__index}}` from `__index.html`.
+`index.html`, `feed.rss`, and `tag.html` use a [double-underscore-prefixed]
+template partial of the same name e.g., `{{__index}}` from `__index.html`.
{% endraw %}
+`tags.html` has access to an associative array of `all_tags` mapped to the
+number of entries tagged by that tag.
+
`sitemap.xml` has access to an array of slugs with the `slugs` key.
## Caveats
@@ -156,6 +162,7 @@ There is an order-of-operations for how files are built, as follows:
- pages e.g. `pages/*.md`
- posts e.g. `posts/*.md`
- drafts e.g. `drafts/*.md`
+- tags (all tags and tagged entries)
- `index.html`
- `sitemap.xml`
- `robots.txt`
@@ -173,6 +180,7 @@ situations. This can be disabled by setting `BIC_OVERWRITE`.
- the demo:
- Mitch's blog:
+- Evan's blog:
## Support
diff --git a/lib/mo b/lib/mo
index c928d7c..efe5353 100644
--- a/lib/mo
+++ b/lib/mo
@@ -15,412 +15,370 @@
#/
#/ Options:
#/
+#/ --allow-function-arguments
+#/ Permit functions to be called with additional arguments. Otherwise,
+#/ the only way to get access to the arguments is to use the
+#/ MO_FUNCTION_ARGS environment variable.
+#/ -d, --debug
+#/ Enable debug logging to stderr.
#/ -u, --fail-not-set
-#/ Fail upon expansion of an unset variable.
+#/ Fail upon expansion of an unset variable. Will silently ignore by
+#/ default. Alternately, set MO_FAIL_ON_UNSET to a non-empty value.
#/ -x, --fail-on-function
-#/ Fail when a function returns a non-zero status code.
+#/ Fail when a function returns a non-zero status code instead of
+#/ silently ignoring it. Alternately, set MO_FAIL_ON_FUNCTION to a
+#/ non-empty value.
+#/ -f, --fail-on-file
+#/ Fail when a file (from command-line or partial) does not exist.
+#/ Alternately, set MO_FAIL_ON_FILE to a non-empty value.
#/ -e, --false
-#/ Treat the string "false" as empty for conditionals.
+#/ Treat the string "false" as empty for conditionals. Alternately,
+#/ set MO_FALSE_IS_EMPTY to a non-empty value.
#/ -h, --help
#/ This message.
#/ -s=FILE, --source=FILE
#/ Load FILE into the environment before processing templates.
-#/ Can be used multiple times.
-#
-# Mo is under a MIT style licence with an additional non-advertising clause.
-# See LICENSE.md for the full text.
-#
-# This is open source! Please feel free to contribute.
-#
-# https://github.com/tests-always-included/mo
+#/ Can be used multiple times. The file must be a valid shell script
+#/ and should only contain variable assignments.
+#/ -o=DELIM, --open=DELIM
+#/ Set the opening delimiter. Default is "{{".
+#/ -c=DELIM, --close=DELIM
+#/ Set the closing delimiter. Default is "}}".
+#/ -- Indicate the end of options. All arguments after this will be
+#/ treated as filenames only. Use when filenames may start with
+#/ hyphens.
+#/
+#/ Mo uses the following environment variables:
+#/
+#/ MO_ALLOW_FUNCTION_ARGUMENTS - When set to a non-empty value, this allows
+#/ functions referenced in templates to receive additional options and
+#/ arguments.
+#/ MO_CLOSE_DELIMITER - The string used when closing a tag. Defaults to "}}".
+#/ Used internally.
+#/ MO_CLOSE_DELIMITER_DEFAULT - The default value of MO_CLOSE_DELIMITER. Used
+#/ when resetting the close delimiter, such as when parsing a partial.
+#/ MO_CURRENT - Variable name to use for ".".
+#/ MO_DEBUG - When set to a non-empty value, additional debug information is
+#/ written to stderr.
+#/ MO_FUNCTION_ARGS - Arguments passed to the function.
+#/ MO_FAIL_ON_FILE - If a filename from the command-line is missing or a
+#/ partial does not exist, abort with an error.
+#/ MO_FAIL_ON_FUNCTION - If a function returns a non-zero status code, abort
+#/ with an error.
+#/ MO_FAIL_ON_UNSET - When set to a non-empty value, expansion of an unset env
+#/ variable will be aborted with an error.
+#/ MO_FALSE_IS_EMPTY - When set to a non-empty value, the string "false" will
+#/ be treated as an empty value for the purposes of conditionals.
+#/ MO_OPEN_DELIMITER - The string used when opening a tag. Defaults to "{{".
+#/ Used internally.
+#/ MO_OPEN_DELIMITER_DEFAULT - The default value of MO_OPEN_DELIMITER. Used
+#/ when resetting the open delimiter, such as when parsing a partial.
+#/ MO_ORIGINAL_COMMAND - Used to find the `mo` program in order to generate a
+#/ help message.
+#/ MO_PARSED - Content that has made it through the template engine.
+#/ MO_STANDALONE_CONTENT - The unparsed content that preceeded the current tag.
+#/ When a standalone tag is encountered, this is checked to see if it only
+#/ contains whitespace. If this and the whitespace condition after a tag is
+#/ met, then this will be reset to $'\n'.
+#/ MO_UNPARSED - Template content yet to make it through the parser.
+#/
+#/ Mo is under a MIT style licence with an additional non-advertising clause.
+#/ See LICENSE.md for the full text.
+#/
+#/ This is open source! Please feel free to contribute.
+#/
+#/ https://github.com/tests-always-included/mo
+#: Disable these warnings for the entire file
+#:
+#: VAR_NAME was modified in a subshell. That change might be lost.
+# shellcheck disable=SC2031
+#:
+#: Modification of VAR_NAME is local (to subshell caused by (..) group).
+# shellcheck disable=SC2030
# Public: Template parser function. Writes templates to stdout.
#
# $0 - Name of the mo file, used for getting the help message.
# $@ - Filenames to parse.
#
-# Options:
-#
-# --allow-function-arguments
-#
-# Permit functions in templates to be called with additional arguments. This
-# puts template data directly in to the path of an eval statement. Use with
-# caution. Not listed in the help because it only makes sense when mo is
-# sourced.
-#
-# -u, --fail-not-set
-#
-# Fail upon expansion of an unset variable. Default behavior is to silently
-# ignore and expand into empty string.
-#
-# -x, --fail-on-function
-#
-# Fail when a function used by a template returns an error status code.
-# Alternately, ou may set the MO_FAIL_ON_FUNCTION environment variable to a
-# non-empty value to enable this behavior.
-#
-# -e, --false
-#
-# Treat "false" as an empty value. You may set the MO_FALSE_IS_EMPTY
-# environment variable instead to a non-empty value to enable this behavior.
-#
-# -h, --help
-#
-# Display a help message.
-#
-# -s=FILE, --source=FILE
-#
-# Source a file into the environment before processing template files.
-# This can be used multiple times.
-#
-# --
-#
-# Used to indicate the end of options. You may optionally use this when
-# filenames may start with two hyphens.
-#
-# Mo uses the following environment variables:
-#
-# MO_ALLOW_FUNCTION_ARGUMENTS - When set to a non-empty value, this allows
-# functions referenced in templates to receive additional
-# options and arguments. This puts the content from the
-# template directly into an eval statement. Use with extreme
-# care.
-# MO_FUNCTION_ARGS - Arguments passed to the function
-# MO_FAIL_ON_FUNCTION - If a function returns a non-zero status code, abort
-# with an error.
-# MO_FAIL_ON_UNSET - When set to a non-empty value, expansion of an unset env
-# variable will be aborted with an error.
-# MO_FALSE_IS_EMPTY - When set to a non-empty value, the string "false" will be
-# treated as an empty value for the purposes of conditionals.
-# MO_ORIGINAL_COMMAND - Used to find the `mo` program in order to generate a
-# help message.
-#
# Returns nothing.
mo() (
- # This function executes in a subshell so IFS is reset.
- # Namespace this variable so we don't conflict with desired values.
- local moContent f2source files doubleHyphens
+ local moSource moFiles moDoubleHyphens moParsed moContent
+ #: This function executes in a subshell; IFS is reset at the end.
IFS=$' \n\t'
- files=()
- doubleHyphens=false
+
+ #: Enable a strict mode. This is also reset at the end.
+ set -eEu -o pipefail
+ moFiles=()
+ moDoubleHyphens=false
+ MO_OPEN_DELIMITER_DEFAULT="{{"
+ MO_CLOSE_DELIMITER_DEFAULT="}}"
+ MO_FUNCTION_CACHE_HIT=()
+ MO_FUNCTION_CACHE_MISS=()
if [[ $# -gt 0 ]]; then
for arg in "$@"; do
- if $doubleHyphens; then
+ if $moDoubleHyphens; then
#: After we encounter two hyphens together, all the rest
#: of the arguments are files.
- files=("${files[@]}" "$arg")
+ moFiles=(${moFiles[@]+"${moFiles[@]}"} "$arg")
else
case "$arg" in
-h|--h|--he|--hel|--help|-\?)
- moUsage "$0"
+ mo::usage "$0"
exit 0
;;
--allow-function-arguments)
- # shellcheck disable=SC2030
MO_ALLOW_FUNCTION_ARGUMENTS=true
;;
-u | --fail-not-set)
- # shellcheck disable=SC2030
MO_FAIL_ON_UNSET=true
;;
-x | --fail-on-function)
- # shellcheck disable=SC2030
MO_FAIL_ON_FUNCTION=true
;;
+ -p | --fail-on-file)
+ MO_FAIL_ON_FILE=true
+ ;;
+
-e | --false)
- # shellcheck disable=SC2030
MO_FALSE_IS_EMPTY=true
;;
-s=* | --source=*)
if [[ "$arg" == --source=* ]]; then
- f2source="${arg#--source=}"
+ moSource="${arg#--source=}"
else
- f2source="${arg#-s=}"
+ moSource="${arg#-s=}"
fi
- if [[ -f "$f2source" ]]; then
+ if [[ -e "$moSource" ]]; then
# shellcheck disable=SC1090
- . "$f2source"
+ . "$moSource"
else
- echo "No such file: $f2source" >&2
+ echo "No such file: $moSource" >&2
exit 1
fi
;;
+ -o=* | --open=*)
+ if [[ "$arg" == --open=* ]]; then
+ MO_OPEN_DELIMITER_DEFAULT="${arg#--open=}"
+ else
+ MO_OPEN_DELIMITER_DEFAULT="${arg#-o=}"
+ fi
+ ;;
+
+ -c=* | --close=*)
+ if [[ "$arg" == --close=* ]]; then
+ MO_CLOSE_DELIMITER_DEFAULT="${arg#--close=}"
+ else
+ MO_CLOSE_DELIMITER_DEFAULT="${arg#-c=}"
+ fi
+ ;;
+
+ -d | --debug)
+ MO_DEBUG=true
+ ;;
+
--)
#: Set a flag indicating we've encountered double hyphens
- doubleHyphens=true
+ moDoubleHyphens=true
+ ;;
+
+ -*)
+ mo::error "Unknown option: $arg (See --help for options)"
;;
*)
#: Every arg that is not a flag or a option should be a file
- files=(${files[@]+"${files[@]}"} "$arg")
+ moFiles=(${moFiles[@]+"${moFiles[@]}"} "$arg")
;;
esac
fi
done
fi
- moGetContent moContent "${files[@]}" || return 1
- moParse "$moContent" "" true
+ mo::debug "Debug enabled"
+ MO_OPEN_DELIMITER="$MO_OPEN_DELIMITER_DEFAULT"
+ MO_CLOSE_DELIMITER="$MO_CLOSE_DELIMITER_DEFAULT"
+ mo::content moContent ${moFiles[@]+"${moFiles[@]}"} || return 1
+ mo::parse moParsed "$moContent"
+ echo -n "$moParsed"
)
-# Internal: Call a function.
-#
-# $1 - Variable for output
-# $2 - Function to call
-# $3 - Content to pass
-# $4 - Additional arguments as a single string
+# Internal: Show a debug message
#
-# This can be dangerous, especially if you are using tags like
-# {{someFunction ; rm -rf / }}
+# $1 - The debug message to show
#
# Returns nothing.
-moCallFunction() {
- local moArgs moContent moFunctionArgs moFunctionResult
-
- moArgs=()
- moTrimWhitespace moFunctionArgs "$4"
-
- # shellcheck disable=SC2031
- if [[ -n "${MO_ALLOW_FUNCTION_ARGUMENTS-}" ]]; then
- # Intentionally bad behavior
- # shellcheck disable=SC2206
- moArgs=($4)
+mo::debug() {
+ if [[ -n "${MO_DEBUG:-}" ]]; then
+ echo "DEBUG ${FUNCNAME[1]:-?} - $1" >&2
fi
-
- moContent=$(echo -n "$3" | MO_FUNCTION_ARGS="$moFunctionArgs" eval "$2" "${moArgs[@]}") || {
- moFunctionResult=$?
- # shellcheck disable=SC2031
- if [[ -n "${MO_FAIL_ON_FUNCTION-}" && "$moFunctionResult" != 0 ]]; then
- echo "Function '$2' with args (${moArgs[*]+"${moArgs[@]}"}) failed with status code $moFunctionResult"
- exit "$moFunctionResult"
- fi
- }
-
- # shellcheck disable=SC2031
- local "$1" && moIndirect "$1" "$moContent"
}
-# Internal: Scan content until the right end tag is found. Creates an array
-# with the following members:
-#
-# [0] = Content before end tag
-# [1] = End tag (complete tag)
-# [2] = Content after end tag
+# Internal: Show a debug message and internal state information
#
-# Everything using this function uses the "standalone tags" logic.
-#
-# $1 - Name of variable for the array
-# $2 - Content
-# $3 - Name of end tag
-# $4 - If -z, do standalone tag processing before finishing
+# No arguments
#
# Returns nothing.
-moFindEndTag() {
- local content remaining scanned standaloneBytes tag
-
- #: Find open tags
- scanned=""
- moSplit content "$2" '{{' '}}'
-
- while [[ "${#content[@]}" -gt 1 ]]; do
- moTrimWhitespace tag "${content[1]}"
-
- #: Restore content[1] before we start using it
- content[1]='{{'"${content[1]}"'}}'
-
- case $tag in
- '#'* | '^'*)
- #: Start another block
- scanned="${scanned}${content[0]}${content[1]}"
- moTrimWhitespace tag "${tag:1}"
- moFindEndTag content "${content[2]}" "$tag" "loop"
- scanned="${scanned}${content[0]}${content[1]}"
- remaining=${content[2]}
- ;;
+mo::debugShowState() {
+ if [[ -z "${MO_DEBUG:-}" ]]; then
+ return
+ fi
- '/'*)
- #: End a block - could be ours
- moTrimWhitespace tag "${tag:1}"
- scanned="$scanned${content[0]}"
-
- if [[ "$tag" == "$3" ]]; then
- #: Found our end tag
- if [[ -z "${4-}" ]] && moIsStandalone standaloneBytes "$scanned" "${content[2]}" true; then
- #: This is also a standalone tag - clean up whitespace
- #: and move those whitespace bytes to the "tag" element
- # shellcheck disable=SC2206
- standaloneBytes=( $standaloneBytes )
- content[1]="${scanned:${standaloneBytes[0]}}${content[1]}${content[2]:0:${standaloneBytes[1]}}"
- scanned="${scanned:0:${standaloneBytes[0]}}"
- content[2]="${content[2]:${standaloneBytes[1]}}"
- fi
+ local moState moTemp moIndex moDots
+
+ mo::escape moTemp "$MO_OPEN_DELIMITER"
+ moState="open: $moTemp"
+ mo::escape moTemp "$MO_CLOSE_DELIMITER"
+ moState="$moState close: $moTemp"
+ mo::escape moTemp "$MO_STANDALONE_CONTENT"
+ moState="$moState standalone: $moTemp"
+ mo::escape moTemp "$MO_CURRENT"
+ moState="$moState current: $moTemp"
+ moIndex=$((${#MO_PARSED} - 20))
+ moDots=...
+
+ if [[ "$moIndex" -lt 0 ]]; then
+ moIndex=0
+ moDots=
+ fi
- local "$1" && moIndirectArray "$1" "$scanned" "${content[1]}" "${content[2]}"
- return 0
- fi
+ mo::escape moTemp "${MO_PARSED:$moIndex}"
+ moState="$moState parsed: $moDots$moTemp"
- scanned="$scanned${content[1]}"
- remaining=${content[2]}
- ;;
+ moDots=...
- *)
- #: Ignore all other tags
- scanned="${scanned}${content[0]}${content[1]}"
- remaining=${content[2]}
- ;;
- esac
+ if [[ "${#MO_UNPARSED}" -le 20 ]]; then
+ moDots=
+ fi
- moSplit content "$remaining" '{{' '}}'
- done
+ mo::escape moTemp "${MO_UNPARSED:0:20}$moDots"
+ moState="$moState unparsed: $moTemp"
+
+ echo "DEBUG ${FUNCNAME[1]:-?} - $moState" >&2
+}
- #: Did not find our closing tag
- scanned="$scanned${content[0]}"
- local "$1" && moIndirectArray "$1" "${scanned}" "" ""
+# Internal: Show an error message and exit
+#
+# $1 - The error message to show
+# $2 - Error code
+#
+# Returns nothing. Exits the program.
+mo::error() {
+ echo "ERROR: $1" >&2
+ exit "${2:-1}"
}
-# Internal: Find the first index of a substring. If not found, sets the
-# index to -1.
+# Internal: Show an error message with a snippet of context and exit
#
-# $1 - Destination variable for the index
-# $2 - Haystack
-# $3 - Needle
+# $1 - The error message to show
+# $2 - The starting point
+# $3 - Error code
#
-# Returns nothing.
-moFindString() {
- local pos string
+# Returns nothing. Exits the program.
+mo::errorNear() {
+ local moEscaped
- string=${2%%"$3"*}
- [[ "$string" == "$2" ]] && pos=-1 || pos=${#string}
- local "$1" && moIndirect "$1" "$pos"
+ mo::escape moEscaped "${2:0:40}"
+ echo "ERROR: $1" >&2
+ echo "ERROR STARTS NEAR: $moEscaped"
+ exit "${3:-1}"
}
-# Internal: Generate a dotted name based on current context and target name.
+# Internal: Displays the usage for mo. Pulls this from the file that
+# contained the `mo` function. Can only work when the right filename
+# comes is the one argument, and that only happens when `mo` is called
+# with `$0` set to this file.
#
-# $1 - Target variable to store results
-# $2 - Context name
-# $3 - Desired variable name
+# $1 - Filename that has the help message
#
# Returns nothing.
-moFullTagName() {
- if [[ -z "${2-}" ]] || [[ "$2" == *.* ]]; then
- local "$1" && moIndirect "$1" "$3"
- else
- local "$1" && moIndirect "$1" "${2}.${3}"
- fi
+mo::usage() {
+ while read -r line; do
+ if [[ "${line:0:2}" == "#/" ]]; then
+ echo "${line:3}"
+ fi
+ done < "$MO_ORIGINAL_COMMAND"
+ echo ""
+ echo "MO_VERSION=$MO_VERSION"
}
-# Internal: Fetches the content to parse into a variable. Can be a list of
+# Internal: Fetches the content to parse into MO_UNPARSED. Can be a list of
# partials for files or the content from stdin.
#
-# $1 - Variable name to assign this content back as
-# $2-@ - File names (optional)
+# $1 - Destination variable name
+# $2-@ - File names (optional), read from stdin otherwise
#
# Returns nothing.
-moGetContent() {
- local moContent moFilename moTarget
+mo::content() {
+ local moTarget moContent moFilename
moTarget=$1
shift
- if [[ "${#@}" -gt 0 ]]; then
- moContent=""
+ moContent=""
+ if [[ "${#@}" -gt 0 ]]; then
for moFilename in "$@"; do
+ mo::debug "Using template to load content from file: $moFilename"
#: This is so relative paths work from inside template files
- moContent="$moContent"'{{>'"$moFilename"'}}'
+ moContent="$moContent$MO_OPEN_DELIMITER>$moFilename$MO_CLOSE_DELIMITER"
done
else
- moLoadFile moContent || return 1
+ mo::debug "Will read content from stdin"
+ mo::contentFile moContent || return 1
fi
- local "$moTarget" && moIndirect "$moTarget" "$moContent"
+ local "$moTarget" && mo::indirect "$moTarget" "$moContent"
}
-# Internal: Indent a string, placing the indent at the beginning of every
-# line that has any content.
+# Internal: Read a file into MO_UNPARSED.
#
-# $1 - Name of destination variable to get an array of lines
-# $2 - The indent string
-# $3 - The string to reindent
+# $1 - Destination variable name.
+# $2 - Filename to load - if empty, defaults to /dev/stdin
#
# Returns nothing.
-moIndentLines() {
- local content fragment len posN posR result trimmed
-
- result=""
-
- #: Remove the period from the end of the string.
- len=$((${#3} - 1))
- content=${3:0:$len}
-
- if [[ -z "${2-}" ]]; then
- local "$1" && moIndirect "$1" "$content"
-
- return 0
- fi
-
- moFindString posN "$content" $'\n'
- moFindString posR "$content" $'\r'
-
- while [[ "$posN" -gt -1 ]] || [[ "$posR" -gt -1 ]]; do
- if [[ "$posN" -gt -1 ]]; then
- fragment="${content:0:$posN + 1}"
- content=${content:$posN + 1}
- else
- fragment="${content:0:$posR + 1}"
- content=${content:$posR + 1}
- fi
-
- moTrimChars trimmed "$fragment" false true " " $'\t' $'\n' $'\r'
-
- if [[ -n "$trimmed" ]]; then
- fragment="$2$fragment"
- fi
-
- result="$result$fragment"
-
- moFindString posN "$content" $'\n'
- moFindString posR "$content" $'\r'
-
- # If the content ends in a newline, do not indent.
- if [[ "$posN" -eq ${#content} ]]; then
- # Special clause for \r\n
- if [[ "$posR" -eq "$((posN - 1))" ]]; then
- posR=-1
- fi
-
- posN=-1
- fi
-
- if [[ "$posR" -eq ${#content} ]]; then
- posR=-1
- fi
- done
-
- moTrimChars trimmed "$content" false true " " $'\t'
-
- if [[ -n "$trimmed" ]]; then
- content="$2$content"
+mo::contentFile() {
+ local moFile moResult moContent
+
+ #: The subshell removes any trailing newlines. We forcibly add
+ #: a dot to the content to preserve all newlines. Reading from
+ #: stdin with a `read` loop does not work as expected, so `cat`
+ #: needs to stay.
+ moFile=${2:-/dev/stdin}
+
+ if [[ -e "$moFile" ]]; then
+ mo::debug "Loading content: $moFile"
+ moContent=$(
+ set +Ee
+ cat -- "$moFile"
+ moResult=$?
+ echo -n '.'
+ exit "$moResult"
+ ) || return 1
+ moContent=${moContent%.} #: Remove last dot
+ elif [[ -n "${MO_FAIL_ON_FILE-}" ]]; then
+ mo::error "No such file: $moFile"
+ else
+ mo::debug "File does not exist: $moFile"
+ moContent=""
fi
- result="$result$content"
-
- local "$1" && moIndirect "$1" "$result"
+ local "$1" && mo::indirect "$1" "$moContent"
}
@@ -432,13 +390,13 @@ moIndentLines() {
# Examples
#
# callFunc () {
-# local "$1" && moIndirect "$1" "the value"
+# local "$1" && mo::indirect "$1" "the value"
# }
# callFunc dest
# echo "$dest" # writes "the value"
#
# Returns nothing.
-moIndirect() {
+mo::indirect() {
unset -v "$1"
printf -v "$1" '%s' "$2"
}
@@ -446,658 +404,1580 @@ moIndirect() {
# Internal: Send an array as a variable up to caller of a function
#
-# $1 - Variable name
+# $1 - Variable name
# $2-@ - Array elements
#
# Examples
#
# callFunc () {
# local myArray=(one two three)
-# local "$1" && moIndirectArray "$1" "${myArray[@]}"
+# local "$1" && mo::indirectArray "$1" "${myArray[@]}"
# }
# callFunc dest
# echo "${dest[@]}" # writes "one two three"
#
# Returns nothing.
-moIndirectArray() {
+mo::indirectArray() {
unset -v "$1"
- # IFS must be set to a string containing space or unset in order for
- # the array slicing to work regardless of the current IFS setting on
- # bash 3. This is detailed further at
- # https://github.com/fidian/gg-core/pull/7
+ #: IFS must be set to a string containing space or unset in order for
+ #: the array slicing to work regardless of the current IFS setting on
+ #: bash 3. This is detailed further at
+ #: https://github.com/fidian/gg-core/pull/7
eval "$(printf "IFS= %s=(\"\${@:2}\") IFS=%q" "$1" "$IFS")"
}
-# Internal: Determine if a given environment variable exists and if it is
-# an array.
-#
-# $1 - Name of environment variable
-#
-# Be extremely careful. Even if strict mode is enabled, it is not honored
-# in newer versions of Bash. Any errors that crop up here will not be
-# caught automatically.
-#
-# Examples
-#
-# var=(abc)
-# if moIsArray var; then
-# echo "This is an array"
-# echo "Make sure you don't accidentally use \$var"
-# fi
+# Internal: Trim leading characters from MO_UNPARSED
#
-# Returns 0 if the name is not empty, 1 otherwise.
-moIsArray() {
- # Namespace this variable so we don't conflict with what we're testing.
- local moTestResult
+# Returns nothing.
+mo::trimUnparsed() {
+ local moI moC
- moTestResult=$(declare -p "$1" 2>/dev/null) || return 1
- [[ "${moTestResult:0:10}" == "declare -a" ]] && return 0
- [[ "${moTestResult:0:10}" == "declare -A" ]] && return 0
+ moI=0
+ moC=${MO_UNPARSED:0:1}
- return 1
+ while [[ "$moC" == " " || "$moC" == $'\r' || "$moC" == $'\n' || "$moC" == $'\t' ]]; do
+ moI=$((moI + 1))
+ moC=${MO_UNPARSED:$moI:1}
+ done
+
+ if [[ "$moI" != 0 ]]; then
+ MO_UNPARSED=${MO_UNPARSED:$moI}
+ fi
}
-# Internal: Determine if the given name is a defined function.
+# Internal: Remove whitespace and content after whitespace
#
-# $1 - Function name to check
-#
-# Be extremely careful. Even if strict mode is enabled, it is not honored
-# in newer versions of Bash. Any errors that crop up here will not be
-# caught automatically.
+# $1 - Name of the destination variable
+# $2 - The string to chomp
#
-# Examples
+# Returns nothing.
+mo::chomp() {
+ local moTemp moR moN moT
+
+ moR=$'\r'
+ moN=$'\n'
+ moT=$'\t'
+ moTemp=${2%% *}
+ moTemp=${moTemp%%"$moR"*}
+ moTemp=${moTemp%%"$moN"*}
+ moTemp=${moTemp%%"$moT"*}
+
+ local "$1" && mo::indirect "$1" "$moTemp"
+}
+
+
+# Public: Parses text, interpolates mustache tags. Utilizes the current value
+# of MO_OPEN_DELIMITER, MO_CLOSE_DELIMITER, and MO_STANDALONE_CONTENT. Those
+# three variables shouldn't be changed by user-defined functions.
#
-# moo () {
-# echo "This is a function"
-# }
-# if moIsFunction moo; then
-# echo "moo is a defined function"
-# fi
+# $1 - Destination variable name - where to store the finished content
+# $2 - Content to parse
+# $3 - Preserve standalone status/content - truthy if not empty. When set to a
+# value, that becomes the standalone content value
#
-# Returns 0 if the name is a function, 1 otherwise.
-moIsFunction() {
- local functionList functionName
+# Returns nothing.
+mo::parse() {
+ local moOldParsed moOldStandaloneContent moOldUnparsed moResult
+
+ #: The standalone content is a trick to make the standalone tag detection
+ #: possible. When it's set to content with a newline and if the tag supports
+ #: it, the standalone content check happens. This check ensures only
+ #: whitespace is after the last newline up to the tag, and only whitespace
+ #: is after the tag up to the next newline. If that is the case, remove
+ #: whitespace and the trailing newline. By setting this to $'\n', we're
+ #: saying we are at the beginning of content.
+ mo::debug "Starting parse of ${#2} bytes"
+ moOldParsed=${MO_PARSED:-}
+ moOldUnparsed=${MO_UNPARSED:-}
+ MO_PARSED=""
+ MO_UNPARSED="$2"
+
+ if [[ -z "${3:-}" ]]; then
+ moOldStandaloneContent=${MO_STANDALONE_CONTENT:-}
+ MO_STANDALONE_CONTENT=$'\n'
+ else
+ MO_STANDALONE_CONTENT=$3
+ fi
- functionList=$(declare -F)
- # shellcheck disable=SC2206
- functionList=( ${functionList//declare -f /} )
+ MO_CURRENT=${MO_CURRENT:-}
+ mo::parseInternal
+ moResult="$MO_PARSED$MO_UNPARSED"
+ MO_PARSED=$moOldParsed
+ MO_UNPARSED=$moOldUnparsed
- for functionName in "${functionList[@]}"; do
- if [[ "$functionName" == "$1" ]]; then
- return 0
- fi
- done
+ if [[ -z "${3:-}" ]]; then
+ MO_STANDALONE_CONTENT=$moOldStandaloneContent
+ fi
- return 1
+ local "$1" && mo::indirect "$1" "$moResult"
}
-# Internal: Determine if the tag is a standalone tag based on whitespace
-# before and after the tag.
-#
-# Passes back a string containing two numbers in the format "BEFORE AFTER"
-# like "27 10". It indicates the number of bytes remaining in the "before"
-# string (27) and the number of bytes to trim in the "after" string (10).
-# Useful for string manipulation:
-#
-# $1 - Variable to set for passing data back
-# $2 - Content before the tag
-# $3 - Content after the tag
-# $4 - true/false: is this the beginning of the content?
-#
-# Examples
+# Internal: Parse MO_UNPARSED, writing content to MO_PARSED. Interpolates
+# mustache tags.
#
-# moIsStandalone RESULT "$before" "$after" false || return 0
-# RESULT_ARRAY=( $RESULT )
-# echo "${before:0:${RESULT_ARRAY[0]}}...${after:${RESULT_ARRAY[1]}}"
+# No arguments
#
# Returns nothing.
-moIsStandalone() {
- local afterTrimmed beforeTrimmed char
-
- moTrimChars beforeTrimmed "$2" false true " " $'\t'
- moTrimChars afterTrimmed "$3" true false " " $'\t'
- char=$((${#beforeTrimmed} - 1))
- char=${beforeTrimmed:$char}
-
- # If the content before didn't end in a newline
- if [[ "$char" != $'\n' ]] && [[ "$char" != $'\r' ]]; then
- # and there was content or this didn't start the file
- if [[ -n "$char" ]] || ! $4; then
- # then this is not a standalone tag.
- return 1
+mo::parseInternal() {
+ local moChunk
+
+ mo::debug "Starting parse"
+
+ while [[ -n "$MO_UNPARSED" ]]; do
+ mo::debugShowState
+ moChunk=${MO_UNPARSED%%"$MO_OPEN_DELIMITER"*}
+ MO_PARSED="$MO_PARSED$moChunk"
+ MO_STANDALONE_CONTENT="$MO_STANDALONE_CONTENT$moChunk"
+ MO_UNPARSED=${MO_UNPARSED:${#moChunk}}
+
+ if [[ -n "$MO_UNPARSED" ]]; then
+ MO_UNPARSED=${MO_UNPARSED:${#MO_OPEN_DELIMITER}}
+ mo::trimUnparsed
+
+ case "$MO_UNPARSED" in
+ '#'*)
+ #: Loop, if/then, or pass content through function
+ mo::parseBlock false
+ ;;
+
+ '^'*)
+ #: Display section if named thing does not exist
+ mo::parseBlock true
+ ;;
+
+ '>'*)
+ #: Load partial - get name of file relative to cwd
+ mo::parsePartial
+ ;;
+
+ '/'*)
+ #: Closing tag
+ mo::errorNear "Unbalanced close tag" "$MO_UNPARSED"
+ ;;
+
+ '!'*)
+ #: Comment - ignore the tag content entirely
+ mo::parseComment
+ ;;
+
+ '='*)
+ #: Change delimiters
+ #: Any two non-whitespace sequences separated by whitespace.
+ mo::parseDelimiter
+ ;;
+
+ '&'*)
+ #: Unescaped - mo doesn't escape/unescape
+ MO_UNPARSED=${MO_UNPARSED#&}
+ mo::trimUnparsed
+ mo::parseValue
+ ;;
+
+ *)
+ #: Normal environment variable, string, subexpression,
+ #: current value, key, or function call
+ mo::parseValue
+ ;;
+ esac
fi
- fi
+ done
+}
- char=${afterTrimmed:0:1}
- # If the content after doesn't start with a newline and it is something
- if [[ "$char" != $'\n' ]] && [[ "$char" != $'\r' ]] && [[ -n "$char" ]]; then
- # then this is not a standalone tag.
- return 2
- fi
+# Internal: Handle parsing a block
+#
+# $1 - Invert condition ("true" or "false")
+#
+# Returns nothing
+mo::parseBlock() {
+ local moInvertBlock moTokens moTokensString
+
+ moInvertBlock=$1
+ MO_UNPARSED=${MO_UNPARSED:1}
+ mo::tokenizeTagContents moTokens "$MO_CLOSE_DELIMITER"
+ MO_UNPARSED=${MO_UNPARSED#"$MO_CLOSE_DELIMITER"}
+ mo::tokensToString moTokensString "${moTokens[@]:1}"
+ mo::debug "Parsing block: $moTokensString"
- if [[ "$char" == $'\r' ]] && [[ "${afterTrimmed:1:1}" == $'\n' ]]; then
- char="$char"$'\n'
+ if mo::standaloneCheck; then
+ mo::standaloneProcess
fi
- local "$1" && moIndirect "$1" "$((${#beforeTrimmed})) $((${#3} + ${#char} - ${#afterTrimmed}))"
+ if [[ "${moTokens[1]}" == "NAME" ]] && mo::isFunction "${moTokens[2]}"; then
+ mo::parseBlockFunction "$moInvertBlock" "$moTokensString" "${moTokens[@]:1}"
+ elif [[ "${moTokens[1]}" == "NAME" ]] && mo::isArray "${moTokens[2]}"; then
+ mo::parseBlockArray "$moInvertBlock" "$moTokensString" "${moTokens[@]:1}"
+ else
+ mo::parseBlockValue "$moInvertBlock" "$moTokensString" "${moTokens[@]:1}"
+ fi
}
-# Internal: Join / implode an array
+# Internal: Handle parsing a block whose first argument is a function
#
-# $1 - Variable name to receive the joined content
-# $2 - Joiner
-# $3-$* - Elements to join
+# $1 - Invert condition ("true" or "false")
+# $2-@ - The parsed tokens from inside the block tags
#
-# Returns nothing.
-moJoin() {
- local joiner part result target
-
- target=$1
- joiner=$2
- result=$3
- shift 3
+# Returns nothing
+mo::parseBlockFunction() {
+ local moTarget moInvertBlock moTokens moTemp moUnparsed moTokensString
- for part in "$@"; do
- result="$result$joiner$part"
- done
+ moInvertBlock=$1
+ moTokensString=$2
+ shift 2
+ moTokens=(${@+"$@"})
+ mo::debug "Parsing block function: $moTokensString"
+ mo::getContentUntilClose moTemp "$moTokensString"
+ #: Pass unparsed content to the function.
+ #: Keep the updated delimiters if they changed.
+
+ if [[ "$moInvertBlock" != "true" ]]; then
+ mo::evaluateFunction moResult "$moTemp" "${moTokens[@]:1}"
+ MO_PARSED="$MO_PARSED$moResult"
+ fi
- local "$target" && moIndirect "$target" "$result"
+ mo::debug "Done parsing block function: $moTokensString"
}
-# Internal: Read a file into a variable.
+# Internal: Handle parsing a block whose first argument is an array
#
-# $1 - Variable name to receive the file's content
-# $2 - Filename to load - if empty, defaults to /dev/stdin
+# $1 - Invert condition ("true" or "false")
+# $2-@ - The parsed tokens from inside the block tags
#
-# Returns nothing.
-moLoadFile() {
- local content len
-
- # The subshell removes any trailing newlines. We forcibly add
- # a dot to the content to preserve all newlines.
- # As a future optimization, it would be worth considering removing
- # cat and replacing this with a read loop.
+# Returns nothing
+mo::parseBlockArray() {
+ local moInvertBlock moTokens moResult moArrayName moArrayIndexes moArrayIndex moTemp moUnparsed moOpenDelimiterBefore moCloseDelimiterBefore moOpenDelimiterAfter moCloseDelimiterAfter moParsed moTokensString moCurrent
- content=$(cat -- "${2:-/dev/stdin}" && echo '.') || return 1
- len=$((${#content} - 1))
- content=${content:0:$len} # Remove last dot
+ moInvertBlock=$1
+ moTokensString=$2
+ shift 2
+ moTokens=(${@+"$@"})
+ mo::debug "Parsing block array: $moTokensString"
+ moOpenDelimiterBefore=$MO_OPEN_DELIMITER
+ moCloseDelimiterBefore=$MO_CLOSE_DELIMITER
+ mo::getContentUntilClose moTemp "$moTokensString"
+ moOpenDelimiterAfter=$MO_OPEN_DELIMITER
+ moCloseDelimiterAfter=$MO_CLOSE_DELIMITER
+ moArrayName=${moTokens[1]}
+ eval "moArrayIndexes=(\"\${!${moArrayName}[@]}\")"
+
+ if [[ "${#moArrayIndexes[@]}" -lt 1 ]]; then
+ #: No elements
+ if [[ "$moInvertBlock" == "true" ]]; then
+ #: Restore the delimiter before parsing
+ MO_OPEN_DELIMITER=$moOpenDelimiterBefore
+ MO_CLOSE_DELIMITER=$moCloseDelimiterBefore
+ moCurrent=$MO_CURRENT
+ MO_CURRENT=$moArrayName
+ mo::parse moParsed "$moTemp" "blockArrayInvert$MO_STANDALONE_CONTENT"
+ MO_CURRENT=$moCurrent
+ MO_PARSED="$MO_PARSED$moParsed"
+ fi
+ else
+ if [[ "$moInvertBlock" != "true" ]]; then
+ #: Process for each element in the array
+ moUnparsed=$MO_UNPARSED
+
+ for moArrayIndex in "${moArrayIndexes[@]}"; do
+ #: Restore the delimiter before parsing
+ MO_OPEN_DELIMITER=$moOpenDelimiterBefore
+ MO_CLOSE_DELIMITER=$moCloseDelimiterBefore
+ moCurrent=$MO_CURRENT
+ MO_CURRENT=$moArrayName.$moArrayIndex
+ mo::debug "Iterate over array using element: $MO_CURRENT"
+ mo::parse moParsed "$moTemp" "blockArray$MO_STANDALONE_CONTENT"
+ MO_CURRENT=$moCurrent
+ MO_PARSED="$MO_PARSED$moParsed"
+ done
+
+ MO_UNPARSED=$moUnparsed
+ fi
+ fi
- local "$1" && moIndirect "$1" "$content"
+ MO_OPEN_DELIMITER=$moOpenDelimiterAfter
+ MO_CLOSE_DELIMITER=$moCloseDelimiterAfter
+ mo::debug "Done parsing block array: $moTokensString"
}
-# Internal: Process a chunk of content some number of times. Writes output
-# to stdout.
+# Internal: Handle parsing a block whose first argument is a value
#
-# $1 - Content to parse repeatedly
-# $2 - Tag prefix (context name)
-# $3-@ - Names to insert into the parsed content
+# $1 - Invert condition ("true" or "false")
+# $2-@ - The parsed tokens from inside the block tags
#
-# Returns nothing.
-moLoop() {
- local content context contextBase
+# Returns nothing
+mo::parseBlockValue() {
+ local moInvertBlock moTokens moResult moUnparsed moOpenDelimiterBefore moOpenDelimiterAfter moCloseDelimiterBefore moCloseDelimiterAfter moParsed moTemp moTokensString moCurrent
- content=$1
- contextBase=$2
+ moInvertBlock=$1
+ moTokensString=$2
shift 2
+ moTokens=(${@+"$@"})
+ mo::debug "Parsing block value: $moTokensString"
+ moOpenDelimiterBefore=$MO_OPEN_DELIMITER
+ moCloseDelimiterBefore=$MO_CLOSE_DELIMITER
+ mo::getContentUntilClose moTemp "$moTokensString"
+ moOpenDelimiterAfter=$MO_OPEN_DELIMITER
+ moCloseDelimiterAfter=$MO_CLOSE_DELIMITER
+
+ #: Variable, value, or list of mixed things
+ mo::evaluateListOfSingles moResult "${moTokens[@]}"
+
+ if mo::isTruthy "$moResult" "$moInvertBlock"; then
+ mo::debug "Block is truthy: $moResult"
+ #: Restore the delimiter before parsing
+ MO_OPEN_DELIMITER=$moOpenDelimiterBefore
+ MO_CLOSE_DELIMITER=$moCloseDelimiterBefore
+ moCurrent=$MO_CURRENT
+ MO_CURRENT=${moTokens[1]}
+ mo::parse moParsed "$moTemp" "blockValue$MO_STANDALONE_CONTENT"
+ MO_PARSED="$MO_PARSED$moParsed"
+ MO_CURRENT=$moCurrent
+ fi
- while [[ "${#@}" -gt 0 ]]; do
- moFullTagName context "$contextBase" "$1"
- moParse "$content" "$context" false
- shift
- done
+ MO_OPEN_DELIMITER=$moOpenDelimiterAfter
+ MO_CLOSE_DELIMITER=$moCloseDelimiterAfter
+ mo::debug "Done parsing block value: $moTokensString"
}
-# Internal: Parse a block of text, writing the result to stdout.
+# Internal: Handle parsing a partial
#
-# $1 - Block of text to change
-# $2 - Current name (the variable NAME for what {{.}} means)
-# $3 - true when no content before this, false otherwise
+# No arguments.
#
-# Returns nothing.
-moParse() {
- # Keep naming variables mo* here to not overwrite needed variables
- # used in the string replacements
- local moArgs moBlock moContent moCurrent moIsBeginning moNextIsBeginning moTag
-
- moCurrent=$2
- moIsBeginning=$3
-
- # Find open tags
- moSplit moContent "$1" '{{' '}}'
-
- while [[ "${#moContent[@]}" -gt 1 ]]; do
- moTrimWhitespace moTag "${moContent[1]}"
- moNextIsBeginning=false
-
- case $moTag in
- '#'*)
- # Loop, if/then, or pass content through function
- # Sets context
- moStandaloneAllowed moContent "${moContent[@]}" "$moIsBeginning"
- moTrimWhitespace moTag "${moTag:1}"
-
- # Split arguments from the tag name. Arguments are passed to
- # functions.
- moArgs=$moTag
- moTag=${moTag%% *}
- moTag=${moTag%%$'\t'*}
- moArgs=${moArgs:${#moTag}}
- moFindEndTag moBlock "$moContent" "$moTag"
- moFullTagName moTag "$moCurrent" "$moTag"
-
- if moTest "$moTag"; then
- # Show / loop / pass through function
- if moIsFunction "$moTag"; then
- moCallFunction moContent "$moTag" "${moBlock[0]}" "$moArgs"
- moParse "$moContent" "$moCurrent" false
- moContent="${moBlock[2]}"
- elif moIsArray "$moTag"; then
- eval "moLoop \"\${moBlock[0]}\" \"$moTag\" \"\${!${moTag}[@]}\""
- else
- moParse "${moBlock[0]}" "$moCurrent" true
- fi
- fi
+# Indentation will be applied to the entire partial's contents before parsing.
+# This indentation is based on the whitespace that ends the previously parsed
+# content.
+#
+# Returns nothing
+mo::parsePartial() {
+ local moFilename moResult moIndentation moN moR moTemp moT
- moContent="${moBlock[2]}"
- ;;
+ MO_UNPARSED=${MO_UNPARSED:1}
+ mo::trimUnparsed
+ mo::chomp moFilename "${MO_UNPARSED%%"$MO_CLOSE_DELIMITER"*}"
+ MO_UNPARSED="${MO_UNPARSED#*"$MO_CLOSE_DELIMITER"}"
+ moIndentation=""
- '>'*)
- # Load partial - get name of file relative to cwd
- moPartial moContent "${moContent[@]}" "$moIsBeginning" "$moCurrent"
- moNextIsBeginning=${moContent[1]}
- moContent=${moContent[0]}
- ;;
+ if mo::standaloneCheck; then
+ moN=$'\n'
+ moR=$'\r'
+ moT=$'\t'
+ moIndentation="$moN${MO_PARSED//"$moR"/"$moN"}"
+ moIndentation=${moIndentation##*"$moN"}
+ moTemp=${moIndentation// }
+ moTemp=${moTemp//"$moT"}
- '/'*)
- # Closing tag - If hit in this loop, we simply ignore
- # Matching tags are found in moFindEndTag
- moStandaloneAllowed moContent "${moContent[@]}" "$moIsBeginning"
- ;;
+ if [[ -n "$moTemp" ]]; then
+ moIndentation=
+ fi
- '^'*)
- # Display section if named thing does not exist
- moStandaloneAllowed moContent "${moContent[@]}" "$moIsBeginning"
- moTrimWhitespace moTag "${moTag:1}"
- moFindEndTag moBlock "$moContent" "$moTag"
- moFullTagName moTag "$moCurrent" "$moTag"
+ mo::debug "Adding indentation to partial: '$moIndentation'"
+ mo::standaloneProcess
+ fi
- if ! moTest "$moTag"; then
- moParse "${moBlock[0]}" "$moCurrent" false "$moCurrent"
- fi
+ mo::debug "Parsing partial: $moFilename"
- moContent="${moBlock[2]}"
- ;;
+ #: Execute in subshell to preserve current cwd and environment
+ moResult=$(
+ #: It would be nice to remove `dirname` and use a function instead,
+ #: but that is difficult when only given filenames.
+ cd "$(dirname -- "$moFilename")" || exit 1
+ echo "$(
+ local moPartialContent moPartialParsed
- '!'*)
- # Comment - ignore the tag content entirely
- # Trim spaces/tabs before the comment
- moStandaloneAllowed moContent "${moContent[@]}" "$moIsBeginning"
- ;;
+ if ! mo::contentFile moPartialContent "${moFilename##*/}"; then
+ exit 1
+ fi
- .)
- # Current content (environment variable or function)
- moStandaloneDenied moContent "${moContent[@]}"
- moShow "$moCurrent" "$moCurrent"
- ;;
+ #: Reset delimiters before parsing
+ mo::indentLines moPartialContent "$moIndentation" "$moPartialContent"
+ MO_OPEN_DELIMITER="$MO_OPEN_DELIMITER_DEFAULT"
+ MO_CLOSE_DELIMITER="$MO_CLOSE_DELIMITER_DEFAULT"
+ mo::parse moPartialParsed "$moPartialContent"
- '=')
- # Change delimiters
- # Any two non-whitespace sequences separated by whitespace.
- # This tag is ignored.
- moStandaloneAllowed moContent "${moContent[@]}" "$moIsBeginning"
- ;;
+ #: Fix bash handling of subshells and keep trailing whitespace.
+ echo -n "$moPartialParsed."
+ )" || exit 1
+ ) || exit 1
- '{'*)
- # Unescaped - split on }}} not }}
- moStandaloneDenied moContent "${moContent[@]}"
- moContent="${moTag:1}"'}}'"$moContent"
- moSplit moContent "$moContent" '}}}'
- moTrimWhitespace moTag "${moContent[0]}"
- moArgs=$moTag
- moTag=${moTag%% *}
- moTag=${moTag%%$'\t'*}
- moArgs=${moArgs:${#moTag}}
- moFullTagName moTag "$moCurrent" "$moTag"
- moContent=${moContent[1]}
-
- # Now show the value
- # Quote moArgs here, do not quote it later.
- moShow "$moTag" "$moCurrent" "$moArgs"
- ;;
+ if [[ -z "$moResult" ]]; then
+ mo::debug "Error detected when trying to read the file"
+ exit 1
+ fi
- '&'*)
- # Unescaped
- moStandaloneDenied moContent "${moContent[@]}"
- moTrimWhitespace moTag "${moTag:1}"
- moFullTagName moTag "$moCurrent" "$moTag"
- moShow "$moTag" "$moCurrent"
- ;;
+ MO_PARSED="$MO_PARSED${moResult%.}"
+}
- *)
- # Normal environment variable or function call
- moStandaloneDenied moContent "${moContent[@]}"
- moArgs=$moTag
- moTag=${moTag%% *}
- moTag=${moTag%%$'\t'*}
- moArgs=${moArgs:${#moTag}}
- moFullTagName moTag "$moCurrent" "$moTag"
-
- # Quote moArgs here, do not quote it later.
- moShow "$moTag" "$moCurrent" "$moArgs"
- ;;
- esac
- moIsBeginning=$moNextIsBeginning
- moSplit moContent "$moContent" '{{' '}}'
- done
+# Internal: Handle parsing a comment
+#
+# No arguments.
+#
+# Returns nothing
+mo::parseComment() {
+ local moContent moContent
+
+ MO_UNPARSED=${MO_UNPARSED#*"$MO_CLOSE_DELIMITER"}
+ mo::debug "Parsing comment"
- echo -n "${moContent[0]}"
+ if mo::standaloneCheck; then
+ mo::standaloneProcess
+ fi
}
-# Internal: Process a partial.
-#
-# Indentation should be applied to the entire partial.
-#
-# This sends back the "is beginning" flag because the newline after a
-# standalone partial is consumed. That newline is very important in the middle
-# of content. We send back this flag to reset the processing loop's
-# `moIsBeginning` variable, so the software thinks we are back at the
-# beginning of a file and standalone processing continues to work.
-#
-# Prefix all variables.
+# Internal: Handle parsing the change of delimiters
#
-# $1 - Name of destination variable. Element [0] is the content, [1] is the
-# true/false flag indicating if we are at the beginning of content.
-# $2 - Content before the tag that was not yet written
-# $3 - Tag content
-# $4 - Content after the tag
-# $5 - true/false: is this the beginning of the content?
-# $6 - Current context name
+# No arguments.
#
-# Returns nothing.
-moPartial() {
- # Namespace variables here to prevent conflicts.
- local moContent moFilename moIndent moIsBeginning moPartial moStandalone moUnindented
-
- if moIsStandalone moStandalone "$2" "$4" "$5"; then
- # shellcheck disable=SC2206
- moStandalone=( $moStandalone )
- echo -n "${2:0:${moStandalone[0]}}"
- moIndent=${2:${moStandalone[0]}}
- moContent=${4:${moStandalone[1]}}
- moIsBeginning=true
- else
- moIndent=""
- echo -n "$2"
- moContent=$4
- moIsBeginning=$5
- fi
-
- moTrimWhitespace moFilename "${3:1}"
+# Returns nothing
+mo::parseDelimiter() {
+ local moContent moOpen moClose
- # Execute in subshell to preserve current cwd and environment
- (
- # It would be nice to remove `dirname` and use a function instead,
- # but that's difficult when you're only given filenames.
- cd "$(dirname -- "$moFilename")" || exit 1
- moUnindented="$(
- moLoadFile moPartial "${moFilename##*/}" || exit 1
- moParse "${moPartial}" "$6" true
+ MO_UNPARSED=${MO_UNPARSED:1}
+ mo::trimUnparsed
+ mo::chomp moOpen "$MO_UNPARSED"
+ MO_UNPARSED=${MO_UNPARSED:${#moOpen}}
+ mo::trimUnparsed
+ mo::chomp moClose "${MO_UNPARSED%%="$MO_CLOSE_DELIMITER"*}"
+ MO_UNPARSED=${MO_UNPARSED#*="$MO_CLOSE_DELIMITER"}
+ mo::debug "Parsing delimiters: $moOpen $moClose"
- # Fix bash handling of subshells and keep trailing whitespace.
- # This is removed in moIndentLines.
- echo -n "."
- )" || exit 1
- moIndentLines moPartial "$moIndent" "$moUnindented"
- echo -n "$moPartial"
- ) || exit 1
+ if mo::standaloneCheck; then
+ mo::standaloneProcess
+ fi
- # If this is a standalone tag, the trailing newline after the tag is
- # removed and the contents of the partial are added, which typically
- # contain a newline. We need to send a signal back to the processing
- # loop that the moIsBeginning flag needs to be turned on again.
- #
- # [0] is the content, [1] is that flag.
- local "$1" && moIndirectArray "$1" "$moContent" "$moIsBeginning"
+ MO_OPEN_DELIMITER="$moOpen"
+ MO_CLOSE_DELIMITER="$moClose"
}
-# Internal: Show an environment variable or the output of a function to
-# stdout.
+# Internal: Handle parsing value or function call
#
-# Limit/prefix any variables used.
+# No arguments.
#
-# $1 - Name of environment variable or function
-# $2 - Current context
-# $3 - Arguments string if $1 is a function
-#
-# Returns nothing.
-moShow() {
- # Namespace these variables
- local moJoined moNameParts moContent
+# Returns nothing
+mo::parseValue() {
+ local moUnparsedOriginal moTokens
- if moIsFunction "$1"; then
- moCallFunction moContent "$1" "" "$3"
- moParse "$moContent" "$2" false
- return 0
+ moUnparsedOriginal=$MO_UNPARSED
+ mo::tokenizeTagContents moTokens "$MO_CLOSE_DELIMITER"
+ mo::evaluate moResult "${moTokens[@]:1}"
+ MO_PARSED="$MO_PARSED$moResult"
+
+ if [[ "${MO_UNPARSED:0:${#MO_CLOSE_DELIMITER}}" != "$MO_CLOSE_DELIMITER" ]]; then
+ mo::errorNear "Did not find closing tag" "$moUnparsedOriginal"
fi
- moSplit moNameParts "$1" "."
+ if mo::standaloneCheck; then
+ mo::standaloneProcess
+ fi
- if [[ -z "${moNameParts[1]-}" ]]; then
- if moIsArray "$1"; then
- eval moJoin moJoined "," "\${$1[@]}"
- echo -n "$moJoined"
- else
- # shellcheck disable=SC2031
- if moTestVarSet "$1"; then
- echo -n "${!1}"
- elif [[ -n "${MO_FAIL_ON_UNSET-}" ]]; then
- echo "Env variable not set: $1" >&2
- exit 1
- fi
+ MO_UNPARSED=${MO_UNPARSED:${#MO_CLOSE_DELIMITER}}
+}
+
+
+# Internal: Determine if the given name is a defined function.
+#
+# $1 - Function name to check
+#
+# Be extremely careful. Even if strict mode is enabled, it is not honored
+# in newer versions of Bash. Any errors that crop up here will not be
+# caught automatically.
+#
+# Examples
+#
+# moo () {
+# echo "This is a function"
+# }
+# if mo::isFunction moo; then
+# echo "moo is a defined function"
+# fi
+#
+# Returns 0 if the name is a function, 1 otherwise.
+mo::isFunction() {
+ local moFunctionName
+
+ for moFunctionName in "${MO_FUNCTION_CACHE_HIT[@]}"; do
+ if [[ "$moFunctionName" == "$1" ]]; then
+ return 0
+ fi
+ done
+
+ for moFunctionName in "${MO_FUNCTION_CACHE_MISS[@]}"; do
+ if [[ "$moFunctionName" == "$1" ]]; then
+ return 1
+ fi
+ done
+
+ if declare -F "$1" &> /dev/null; then
+ MO_FUNCTION_CACHE_HIT=( ${MO_FUNCTION_CACHE_HIT[@]+"${MO_FUNCTION_CACHE_HIT[@]}"} "$1" )
+
+ return 0
+ fi
+
+ MO_FUNCTION_CACHE_MISS=( ${MO_FUNCTION_CACHE_MISS[@]+"${MO_FUNCTION_CACHE_MISS[@]}"} "$1" )
+
+ return 1
+}
+
+
+# Internal: Determine if a given environment variable exists and if it is
+# an array.
+#
+# $1 - Name of environment variable
+#
+# Be extremely careful. Even if strict mode is enabled, it is not honored
+# in newer versions of Bash. Any errors that crop up here will not be
+# caught automatically.
+#
+# Examples
+#
+# var=(abc)
+# if moIsArray var; then
+# echo "This is an array"
+# echo "Make sure you don't accidentally use \$var"
+# fi
+#
+# Returns 0 if the name is not empty, 1 otherwise.
+mo::isArray() {
+ #: Namespace this variable so we don't conflict with what we're testing.
+ local moTestResult
+
+ moTestResult=$(declare -p "$1" 2>/dev/null) || return 1
+ [[ "${moTestResult:0:10}" == "declare -a" ]] && return 0
+ [[ "${moTestResult:0:10}" == "declare -A" ]] && return 0
+
+ return 1
+}
+
+
+# Internal: Determine if an array index exists.
+#
+# $1 - Variable name to check
+# $2 - The index to check
+#
+# Has to check if the variable is an array and if the index is valid for that
+# type of array.
+#
+# Returns true (0) if everything was ok, 1 if there's any condition that fails.
+mo::isArrayIndexValid() {
+ local moDeclare moTest
+
+ moDeclare=$(declare -p "$1")
+ moTest=""
+
+ if [[ "${moDeclare:0:10}" == "declare -a" ]]; then
+ #: Numerically indexed array - must check if the index looks like a
+ #: number because using a string to index a numerically indexed array
+ #: will appear like it worked.
+ if [[ "$2" == "0" ]] || [[ "$2" =~ ^[1-9][0-9]*$ ]]; then
+ #: Index looks like a number
+ eval "moTest=\"\${$1[$2]+ok}\""
fi
+ elif [[ "${moDeclare:0:10}" == "declare -A" ]]; then
+ #: Associative array
+ eval "moTest=\"\${$1[$2]+ok}\""
+ fi
+
+ if [[ -n "$moTest" ]]; then
+ return 0;
+ fi
+
+ return 1
+}
+
+
+# Internal: Determine if a variable is assigned, even if it is assigned an empty
+# value.
+#
+# $1 - Variable name to check.
+#
+# Can not use logic like this in case invalid variable names are passed.
+# [[ "${!1-a}" == "${!1-b}" ]]
+#
+# Returns true (0) if the variable is set, 1 if the variable is unset.
+mo::isVarSet() {
+ if ! declare -p "$1" &> /dev/null; then
+ return 1
+ fi
+
+ return 0
+}
+
+
+# Internal: Determine if a value is considered truthy.
+#
+# $1 - The value to test
+# $2 - Invert the value, either "true" or "false"
+#
+# Returns true (0) if truthy, 1 otherwise.
+mo::isTruthy() {
+ local moTruthy
+
+ moTruthy=true
+
+ if [[ -z "${1-}" ]]; then
+ moTruthy=false
+ elif [[ -n "${MO_FALSE_IS_EMPTY-}" ]] && [[ "${1-}" == "false" ]]; then
+ moTruthy=false
+ fi
+
+ #: XOR the results
+ #: moTruthy inverse desiredResult
+ #: true false true
+ #: true true false
+ #: false false false
+ #: false true true
+ if [[ "$moTruthy" == "$2" ]]; then
+ mo::debug "Value is falsy, test result: $moTruthy inverse: $2"
+ return 1
+ fi
+
+ mo::debug "Value is truthy, test result: $moTruthy inverse: $2"
+ return 0
+}
+
+
+# Internal: Convert token list to values
+#
+# $1 - Destination variable name
+# $2-@ - Tokens to convert
+#
+# Sample call:
+#
+# mo::evaluate dest NAME username VALUE abc123 PAREN 2
+#
+# Returns nothing.
+mo::evaluate() {
+ local moTarget moStack moValue moType moIndex moCombined moResult
+
+ moTarget=$1
+ shift
+
+ #: Phase 1 - remove all command tokens (PAREN, BRACE)
+ moStack=()
+
+ while [[ $# -gt 0 ]]; do
+ case "$1" in
+ PAREN|BRACE)
+ moType=$1
+ moValue=$2
+ mo::debug "Combining $moValue tokens"
+ moIndex=$((${#moStack[@]} - (2 * moValue)))
+ mo::evaluateListOfSingles moCombined "${moStack[@]:$moIndex}"
+
+ if [[ "$moType" == "PAREN" ]]; then
+ moStack=("${moStack[@]:0:$moIndex}" NAME "$moCombined")
+ else
+ moStack=("${moStack[@]:0:$moIndex}" VALUE "$moCombined")
+ fi
+ ;;
+
+ *)
+ moStack=(${moStack[@]+"${moStack[@]}"} "$1" "$2")
+ ;;
+ esac
+
+ shift 2
+ done
+
+ #: Phase 2 - check if this is a function or if we should just concatenate values
+ if [[ "${moStack[0]:-}" == "NAME" ]] && mo::isFunction "${moStack[1]}"; then
+ #: Special case - if the first argument is a function, then the rest are
+ #: passed to the function.
+ mo::debug "Evaluating function: ${moStack[1]}"
+ mo::evaluateFunction moResult "" "${moStack[@]:1}"
else
- # Further subindexes are disallowed
- eval "echo -n \"\${${moNameParts[0]}[${moNameParts[1]%%.*}]}\""
+ #: Concatenate
+ mo::debug "Concatenating ${#moStack[@]} stack items"
+ mo::evaluateListOfSingles moResult ${moStack[@]+"${moStack[@]}"}
fi
+
+ local "$moTarget" && mo::indirect "$moTarget" "$moResult"
}
-# Internal: Split a larger string into an array.
+# Internal: Convert an argument list to individual values.
#
-# $1 - Destination variable
-# $2 - String to split
-# $3 - Starting delimiter
-# $4 - Ending delimiter (optional)
+# $1 - Destination variable name
+# $2-@ - A list of argument types and argument name/value.
+#
+# This assumes each value is separate from the rest. In contrast, mo::evaluate
+# will pass all arguments to a function if the first value is a function.
+#
+# Sample call:
+#
+# mo::evaluateListOfSingles dest NAME username VALUE abc123
#
# Returns nothing.
-moSplit() {
- local pos result
+mo::evaluateListOfSingles() {
+ local moResult moTarget moTemp
- result=( "$2" )
- moFindString pos "${result[0]}" "$3"
+ moTarget=$1
+ shift
+ moResult=""
- if [[ "$pos" -ne -1 ]]; then
- # The first delimiter was found
- result[1]=${result[0]:$pos + ${#3}}
- result[0]=${result[0]:0:$pos}
+ while [[ $# -gt 1 ]]; do
+ mo::evaluateSingle moTemp "$1" "$2"
+ moResult="$moResult$moTemp"
+ shift 2
+ done
+
+ mo::debug "Evaluated list of singles: $moResult"
+
+ local "$moTarget" && mo::indirect "$moTarget" "$moResult"
+}
- if [[ -n "${4-}" ]]; then
- moFindString pos "${result[1]}" "$4"
- if [[ "$pos" -ne -1 ]]; then
- # The second delimiter was found
- result[2]="${result[1]:$pos + ${#4}}"
- result[1]="${result[1]:0:$pos}"
+# Internal: Evaluate a single argument
+#
+# $1 - Name of variable for result
+# $2 - Type of argument, either NAME or VALUE
+# $3 - Argument
+#
+# Returns nothing
+mo::evaluateSingle() {
+ local moResult moType moArg
+
+ moType=$2
+ moArg=$3
+ mo::debug "Evaluating $moType: $moArg ($MO_CURRENT)"
+
+ if [[ "$moType" == "VALUE" ]]; then
+ moResult=$moArg
+ elif [[ "$moArg" == "." ]]; then
+ mo::evaluateVariable moResult ""
+ elif [[ "$moArg" == "@key" ]]; then
+ mo::evaluateKey moResult
+ elif mo::isFunction "$moArg"; then
+ mo::evaluateFunction moResult "" "$moArg"
+ else
+ mo::evaluateVariable moResult "$moArg"
+ fi
+
+ local "$1" && mo::indirect "$1" "$moResult"
+}
+
+
+# Internal: Return the value for @key based on current's name
+#
+# $1 - Name of variable for result
+#
+# Returns nothing
+mo::evaluateKey() {
+ local moResult
+
+ if [[ "$MO_CURRENT" == *.* ]]; then
+ moResult="${MO_CURRENT#*.}"
+ else
+ moResult="${MO_CURRENT}"
+ fi
+
+ local "$1" && mo::indirect "$1" "$moResult"
+}
+
+
+# Internal: Handle a variable name
+#
+# $1 - Destination variable name
+# $2 - Variable name
+#
+# Returns nothing.
+mo::evaluateVariable() {
+ local moResult moArg moNameParts
+
+ moArg=$2
+ moResult=""
+ mo::findVariableName moNameParts "$moArg"
+ mo::debug "Evaluate variable ($moArg, $MO_CURRENT): ${moNameParts[*]}"
+
+ if [[ -z "${moNameParts[1]}" ]]; then
+ if mo::isArray "${moNameParts[0]}"; then
+ eval mo::join moResult "," "\${${moNameParts[0]}[@]}"
+ else
+ if mo::isVarSet "${moNameParts[0]}"; then
+ moResult=${moNameParts[0]}
+ moResult="${!moResult}"
+ elif [[ -n "${MO_FAIL_ON_UNSET-}" ]]; then
+ mo::error "Environment variable not set: ${moNameParts[0]}"
fi
fi
+ else
+ if mo::isArray "${moNameParts[0]}"; then
+ eval "set +u;moResult=\"\${${moNameParts[0]}[${moNameParts[1]%%.*}]}\""
+ else
+ mo::error "Unable to index a scalar as an array: $moArg"
+ fi
fi
- local "$1" && moIndirectArray "$1" "${result[@]}"
+ local "$1" && mo::indirect "$1" "$moResult"
}
-# Internal: Handle the content for a standalone tag. This means removing
-# whitespace (not newlines) before a tag and whitespace and a newline after
-# a tag. That is, assuming, that the line is otherwise empty.
+# Internal: Find the name of a variable to use
+#
+# $1 - Destination variable name, receives an array
+# $2 - Variable name from the template
+#
+# The array contains the following values
+# [0] - Variable name
+# [1] - Array index, or empty string
+#
+# Example variables
+# a="a"
+# b="b"
+# c=("c.0" "c.1")
+# d=([b]="d.b" [d]="d.d")
+#
+# Given these inputs (function input, current value), produce these outputs
+# a c => a
+# a c.0 => a
+# b d => d.b
+# b d.d => d.b
+# a d => d.a
+# a d.d => d.a
+# c.0 d => c.0
+# d.b d => d.b
+# '' c => c
+# '' c.0 => c.0
+# Returns nothing.
+mo::findVariableName() {
+ local moVar moNameParts moResultBase moResultIndex moCurrent
+
+ moVar=$2
+ moResultBase=$moVar
+ moResultIndex=""
+
+ if [[ -z "$moVar" ]]; then
+ moResultBase=${MO_CURRENT%%.*}
+
+ if [[ "$MO_CURRENT" == *.* ]]; then
+ moResultIndex=${MO_CURRENT#*.}
+ fi
+ elif [[ "$moVar" == *.* ]]; then
+ mo::debug "Find variable name; name has dot: $moVar"
+ moResultBase=${moVar%%.*}
+ moResultIndex=${moVar#*.}
+ elif [[ -n "$MO_CURRENT" ]]; then
+ moCurrent=${MO_CURRENT%%.*}
+ mo::debug "Find variable name; look in array: $moCurrent"
+
+ if mo::isArrayIndexValid "$moCurrent" "$moVar"; then
+ moResultBase=$moCurrent
+ moResultIndex=$moVar
+ fi
+ fi
+
+ local "$1" && mo::indirectArray "$1" "$moResultBase" "$moResultIndex"
+}
+
+
+# Internal: Join / implode an array
#
-# $1 - Name of destination "content" variable.
-# $2 - Content before the tag that was not yet written
-# $3 - Tag content (not used)
-# $4 - Content after the tag
-# $5 - true/false: is this the beginning of the content?
+# $1 - Variable name to receive the joined content
+# $2 - Joiner
+# $3-@ - Elements to join
#
# Returns nothing.
-moStandaloneAllowed() {
- local bytes
-
- if moIsStandalone bytes "$2" "$4" "$5"; then
- # shellcheck disable=SC2206
- bytes=( $bytes )
- echo -n "${2:0:${bytes[0]}}"
- local "$1" && moIndirect "$1" "${4:${bytes[1]}}"
- else
- echo -n "$2"
- local "$1" && moIndirect "$1" "$4"
+mo::join() {
+ local joiner part result target
+
+ target=$1
+ joiner=$2
+ result=$3
+ shift 3
+
+ for part in "$@"; do
+ result="$result$joiner$part"
+ done
+
+ local "$target" && mo::indirect "$target" "$result"
+}
+
+
+# Internal: Call a function.
+#
+# $1 - Variable for output
+# $2 - Content to pass
+# $3 - Function to call
+# $4-@ - Additional arguments as list of type, value/name
+#
+# Returns nothing.
+mo::evaluateFunction() {
+ local moArgs moContent moFunctionResult moTarget moFunction moTemp moFunctionCall
+
+ moTarget=$1
+ moContent=$2
+ moFunction=$3
+ shift 3
+ moArgs=()
+
+ while [[ $# -gt 1 ]]; do
+ mo::evaluateSingle moTemp "$1" "$2"
+ moArgs=(${moArgs[@]+"${moArgs[@]}"} "$moTemp")
+ shift 2
+ done
+
+ mo::escape moFunctionCall "$moFunction"
+
+ if [[ -n "${MO_ALLOW_FUNCTION_ARGUMENTS-}" ]]; then
+ mo::debug "Function arguments are allowed"
+
+ if [[ ${#moArgs[@]} -gt 0 ]]; then
+ for moTemp in "${moArgs[@]}"; do
+ mo::escape moTemp "$moTemp"
+ moFunctionCall="$moFunctionCall $moTemp"
+ done
+ fi
fi
+
+ mo::debug "Calling function: $moFunctionCall"
+
+ #: Call the function in a subshell for safety. Employ the trick to preserve
+ #: whitespace at the end of the output.
+ moContent=$(
+ export MO_FUNCTION_ARGS=(${moArgs[@]+"${moArgs[@]}"})
+ echo -n "$moContent" | eval "$moFunctionCall ; moFunctionResult=\$? ; echo -n '.' ; exit \"\$moFunctionResult\""
+ ) || {
+ moFunctionResult=$?
+ if [[ -n "${MO_FAIL_ON_FUNCTION-}" && "$moFunctionResult" != 0 ]]; then
+ mo::error "Function failed with status code $moFunctionResult: $moFunctionCall" "$moFunctionResult"
+ fi
+ }
+
+ local "$moTarget" && mo::indirect "$moTarget" "${moContent%.}"
}
-# Internal: Handle the content for a tag that is never "standalone". No
-# adjustments are made for newlines and whitespace.
+# Internal: Check if a tag appears to have only whitespace before it and after
+# it on a line. There must be a new line before and there must be a newline
+# after or the end of a string
#
-# $1 - Name of destination "content" variable.
-# $2 - Content before the tag that was not yet written
-# $3 - Tag content (not used)
-# $4 - Content after the tag
+# No arguments.
+#
+# Returns 0 if this is a standalone tag, 1 otherwise.
+mo::standaloneCheck() {
+ local moContent moN moR moT
+
+ moN=$'\n'
+ moR=$'\r'
+ moT=$'\t'
+
+ #: Check the content before
+ moContent=${MO_STANDALONE_CONTENT//"$moR"/"$moN"}
+
+ #: By default, signal to the next check that this one failed
+ MO_STANDALONE_CONTENT=""
+
+ if [[ "$moContent" != *"$moN"* ]]; then
+ mo::debug "Not a standalone tag - no newline before"
+
+ return 1
+ fi
+
+ moContent=${moContent##*"$moN"}
+ moContent=${moContent//"$moT"/}
+ moContent=${moContent// /}
+
+ if [[ -n "$moContent" ]]; then
+ mo::debug "Not a standalone tag - non-whitespace detected before tag"
+
+ return 1
+ fi
+
+ #: Check the content after
+ moContent=${MO_UNPARSED//"$moR"/"$moN"}
+ moContent=${moContent%%"$moN"*}
+ moContent=${moContent//"$moT"/}
+ moContent=${moContent// /}
+
+ if [[ -n "$moContent" ]]; then
+ mo::debug "Not a standalone tag - non-whitespace detected after tag"
+
+ return 1
+ fi
+
+ #: Signal to the next check that this tag removed content
+ MO_STANDALONE_CONTENT=$'\n'
+
+ return 0
+}
+
+
+# Internal: Process content before and after a tag. Remove prior whitespace up
+# to the previous newline. Remove following whitespace up to and including the
+# next newline.
+#
+# No arguments.
#
# Returns nothing.
-moStandaloneDenied() {
- echo -n "$2"
- local "$1" && moIndirect "$1" "$4"
+mo::standaloneProcess() {
+ local moI moTemp
+
+ mo::debug "Standalone tag - processing content before and after tag"
+ moI=$((${#MO_PARSED} - 1))
+ mo::debug "zero done ${#MO_PARSED}"
+ mo::escape moTemp "$MO_PARSED"
+ mo::debug "$moTemp"
+
+ while [[ "${MO_PARSED:$moI:1}" == " " || "${MO_PARSED:$moI:1}" == $'\t' ]]; do
+ moI=$((moI - 1))
+ done
+
+ if [[ $((moI + 1)) != "${#MO_PARSED}" ]]; then
+ MO_PARSED="${MO_PARSED:0:${moI}+1}"
+ fi
+
+ moI=0
+
+ while [[ "${MO_UNPARSED:${moI}:1}" == " " || "${MO_UNPARSED:${moI}:1}" == $'\t' ]]; do
+ moI=$((moI + 1))
+ done
+
+ if [[ "${MO_UNPARSED:${moI}:1}" == $'\r' ]]; then
+ moI=$((moI + 1))
+ fi
+
+ if [[ "${MO_UNPARSED:${moI}:1}" == $'\n' ]]; then
+ moI=$((moI + 1))
+ fi
+
+ if [[ "$moI" != 0 ]]; then
+ MO_UNPARSED=${MO_UNPARSED:${moI}}
+ fi
}
-# Internal: Determines if the named thing is a function or if it is a
-# non-empty environment variable. When MO_FALSE_IS_EMPTY is set to a
-# non-empty value, then "false" is also treated is an empty value.
+# Internal: Apply indentation before any line that has content in MO_UNPARSED.
#
-# Do not use variables without prefixes here if possible as this needs to
-# check if any name exists in the environment
+# $1 - Destination variable name.
+# $2 - The indentation string.
+# $3 - The content that needs the indentation string prepended on each line.
#
-# $1 - Name of environment variable or function
-# $2 - Current value (our context)
-# MO_FALSE_IS_EMPTY - When set to a non-empty value, this will say the
-# string value "false" is empty.
+# Returns nothing.
+mo::indentLines() {
+ local moContent moIndentation moResult moN moR moChunk
+
+ moIndentation=$2
+ moContent=$3
+
+ if [[ -z "$moIndentation" ]]; then
+ mo::debug "Not applying indentation, empty indentation"
+
+ local "$1" && mo::indirect "$1" "$moContent"
+ return
+ fi
+
+ if [[ -z "$moContent" ]]; then
+ mo::debug "Not applying indentation, empty contents"
+
+ local "$1" && mo::indirect "$1" "$moContent"
+ return
+ fi
+
+ moResult=
+ moN=$'\n'
+ moR=$'\r'
+
+ mo::debug "Applying indentation: '${moIndentation}'"
+
+ while [[ -n "$moContent" ]]; do
+ moChunk=${moContent%%"$moN"*}
+ moChunk=${moChunk%%"$moR"*}
+ moContent=${moContent:${#moChunk}}
+
+ if [[ -n "$moChunk" ]]; then
+ moResult="$moResult$moIndentation$moChunk"
+ fi
+
+ moResult="$moResult${moContent:0:1}"
+ moContent=${moContent:1}
+ done
+
+ local "$1" && mo::indirect "$1" "$moResult"
+}
+
+
+# Internal: Escape a value
#
-# Returns 0 if the name is not empty, 1 otherwise. When MO_FALSE_IS_EMPTY
-# is set, this returns 1 if the name is "false".
-moTest() {
- # Test for functions
- moIsFunction "$1" && return 0
+# $1 - Destination variable name
+# $2 - Value to escape
+#
+# Returns nothing
+mo::escape() {
+ local moResult
- if moIsArray "$1"; then
- # Arrays must have at least 1 element
- eval "[[ \"\${#${1}[@]}\" -gt 0 ]]" && return 0
- else
- # If MO_FALSE_IS_EMPTY is set, then return 1 if the value of
- # the variable is "false".
- # shellcheck disable=SC2031
- [[ -n "${MO_FALSE_IS_EMPTY-}" ]] && [[ "${!1-}" == "false" ]] && return 1
+ moResult=$2
+ moResult=$(declare -p moResult)
+ moResult=${moResult#*=}
+
+ local "$1" && mo::indirect "$1" "$moResult"
+}
+
+
+# Internal: Get the content up to the end of the block by minimally parsing and
+# balancing blocks. Returns the content before the end tag to the caller and
+# removes the content + the end tag from MO_UNPARSED. This can change the
+# delimiters, adjusting MO_OPEN_DELIMITER and MO_CLOSE_DELIMITER.
+#
+# $1 - Destination variable name
+# $2 - Token string to match for a closing tag
+#
+# Returns nothing.
+mo::getContentUntilClose() {
+ local moChunk moResult moTemp moTokensString moTokens moTarget moTagStack moResultTemp
+
+ moTarget=$1
+ moTagStack=("$2")
+ mo::debug "Get content until close tag: ${moTagStack[0]}"
+ moResult=""
+
+ while [[ -n "$MO_UNPARSED" ]] && [[ "${#moTagStack[@]}" -gt 0 ]]; do
+ moChunk=${MO_UNPARSED%%"$MO_OPEN_DELIMITER"*}
+ moResult="$moResult$moChunk"
+ MO_UNPARSED=${MO_UNPARSED:${#moChunk}}
+
+ if [[ -n "$MO_UNPARSED" ]]; then
+ moResultTemp="$MO_OPEN_DELIMITER"
+ MO_UNPARSED=${MO_UNPARSED:${#MO_OPEN_DELIMITER}}
+ mo::getContentTrim moTemp
+ moResultTemp="$moResultTemp$moTemp"
+ mo::debug "First character within tag: ${MO_UNPARSED:0:1}"
+
+ case "$MO_UNPARSED" in
+ '#'*)
+ #: Increase block
+ moResultTemp="$moResultTemp${MO_UNPARSED:0:1}"
+ MO_UNPARSED=${MO_UNPARSED:1}
+ mo::getContentTrim moTemp
+ mo::getContentWithinTag moTemp "$MO_CLOSE_DELIMITER"
+ moResultTemp="$moResultTemp${moTemp[0]}"
+ moTagStack=("${moTemp[1]}" "${moTagStack[@]}")
+ ;;
+
+ '^'*)
+ #: Increase block
+ moResultTemp="$moResultTemp${MO_UNPARSED:0:1}"
+ MO_UNPARSED=${MO_UNPARSED:1}
+ mo::getContentTrim moTemp
+ mo::getContentWithinTag moTemp "$MO_CLOSE_DELIMITER"
+ moResultTemp="$moResultTemp${moTemp[0]}"
+ moTagStack=("${moTemp[1]}" "${moTagStack[@]}")
+ ;;
+
+ '>'*)
+ #: Partial - ignore
+ moResultTemp="$moResultTemp${MO_UNPARSED:0:1}"
+ MO_UNPARSED=${MO_UNPARSED:1}
+ mo::getContentTrim moTemp
+ mo::getContentWithinTag moTemp "$MO_CLOSE_DELIMITER"
+ moResultTemp="$moResultTemp${moTemp[0]}"
+ ;;
+
+ '/'*)
+ #: Decrease block
+ moResultTemp="$moResultTemp${MO_UNPARSED:0:1}"
+ MO_UNPARSED=${MO_UNPARSED:1}
+ mo::getContentTrim moTemp
+ mo::getContentWithinTag moTemp "$MO_CLOSE_DELIMITER"
+
+ if [[ "${moTagStack[0]}" == "${moTemp[1]}" ]]; then
+ moResultTemp="$moResultTemp${moTemp[0]}"
+ moTagStack=("${moTagStack[@]:1}")
+
+ if [[ "${#moTagStack[@]}" -eq 0 ]]; then
+ #: Erase all portions of the close tag
+ moResultTemp=""
+ fi
+ else
+ mo::errorNear "Unbalanced closing tag, expected: ${moTagStack[0]}" "${moTemp[0]}${MO_UNPARSED}"
+ fi
+ ;;
+
+ '!'*)
+ #: Comment - ignore
+ mo::getContentComment moTemp
+ moResultTemp="$moResultTemp$moTemp"
+ ;;
+
+ '='*)
+ #: Change delimiters
+ mo::getContentDelimiter moTemp
+ moResultTemp="$moResultTemp$moTemp"
+ ;;
+
+ '&'*)
+ #: Unescaped - bypass one then ignore
+ moResultTemp="$moResultTemp${MO_UNPARSED:0:1}"
+ MO_UNPARSED=${MO_UNPARSED:1}
+ mo::getContentTrim moTemp
+ moResultTemp="$moResultTemp$moTemp"
+ mo::getContentWithinTag moTemp "$MO_CLOSE_DELIMITER"
+ moResultTemp="$moResultTemp${moTemp[0]}"
+ ;;
+
+ *)
+ #: Normal variable - ignore
+ mo::getContentWithinTag moTemp "$MO_CLOSE_DELIMITER"
+ moResultTemp="$moResultTemp${moTemp[0]}"
+ ;;
+ esac
+
+ moResult="$moResult$moResultTemp"
+ fi
+ done
- # Environment variables must not be empty
- [[ -n "${!1-}" ]] && return 0
+ MO_STANDALONE_CONTENT="$MO_STANDALONE_CONTENT$moResult"
+
+ if mo::standaloneCheck; then
+ moResultTemp=$MO_PARSED
+ MO_PARSED=$moResult
+ mo::standaloneProcess
+ moResult=$MO_PARSED
+ MO_PARSED=$moResultTemp
fi
- return 1
+ local "$moTarget" && mo::indirect "$moTarget" "$moResult"
}
-# Internal: Determine if a variable is assigned, even if it is assigned an empty
-# value.
+
+# Internal: Convert a list of tokens to a string
#
-# $1 - Variable name to check.
+# $1 - Destination variable for the string
+# $2-$@ - Token list
#
-# Returns true (0) if the variable is set, 1 if the variable is unset.
-moTestVarSet() {
- [[ "${!1-a}" == "${!1-b}" ]]
+# Returns nothing.
+mo::tokensToString() {
+ local moTarget moString moTokens
+
+ moTarget=$1
+ shift 1
+ moTokens=("$@")
+ moString=$(declare -p moTokens)
+ moString=${moString#*=}
+
+ local "$moTarget" && mo::indirect "$moTarget" "$moString"
}
-# Internal: Trim the leading whitespace only.
+# Internal: Trims content from MO_UNPARSED, returns trimmed content.
#
-# $1 - Name of destination variable
-# $2 - The string
-# $3 - true/false - trim front?
-# $4 - true/false - trim end?
-# $5-@ - Characters to trim
+# $1 - Destination variable
#
# Returns nothing.
-moTrimChars() {
- local back current front last target varName
+mo::getContentTrim() {
+ local moChar moResult
- target=$1
- current=$2
- front=$3
- back=$4
- last=""
- shift 4 # Remove target, string, trim front flag, trim end flag
-
- while [[ "$current" != "$last" ]]; do
- last=$current
-
- for varName in "$@"; do
- $front && current="${current/#$varName}"
- $back && current="${current/%$varName}"
- done
+ moChar=${MO_UNPARSED:0:1}
+ moResult=""
+
+ while [[ "$moChar" == " " ]] || [[ "$moChar" == $'\r' ]] || [[ "$moChar" == $'\t' ]] || [[ "$moChar" == $'\n' ]]; do
+ moResult="$moResult$moChar"
+ MO_UNPARSED=${MO_UNPARSED:1}
+ moChar=${MO_UNPARSED:0:1}
done
- local "$target" && moIndirect "$target" "$current"
+ local "$1" && mo::indirect "$1" "$moResult"
}
-# Internal: Trim leading and trailing whitespace from a string.
+# Get the content up to and including a close tag
#
-# $1 - Name of variable to store trimmed string
-# $2 - The string
+# $1 - Destination variable
#
# Returns nothing.
-moTrimWhitespace() {
- local result
+mo::getContentComment() {
+ local moResult
+
+ mo::debug "Getting content for comment"
+ moResult=${MO_UNPARSED%%"$MO_CLOSE_DELIMITER"*}
+ MO_UNPARSED=${MO_UNPARSED:${#moResult}}
+
+ if [[ "$MO_UNPARSED" == "$MO_CLOSE_DELIMITER"* ]]; then
+ moResult="$moResult$MO_CLOSE_DELIMITER"
+ MO_UNPARSED=${MO_UNPARSED#"$MO_CLOSE_DELIMITER"}
+ fi
- moTrimChars result "$2" true true $'\r' $'\n' $'\t' " "
- local "$1" && moIndirect "$1" "$result"
+ local "$1" && mo::indirect "$1" "$moResult"
}
-# Internal: Displays the usage for mo. Pulls this from the file that
-# contained the `mo` function. Can only work when the right filename
-# comes is the one argument, and that only happens when `mo` is called
-# with `$0` set to this file.
+# Get the content up to and including a close tag. First two non-whitespace
+# tokens become the new open and close tag.
#
-# $1 - Filename that has the help message
+# $1 - Destination variable
#
# Returns nothing.
-moUsage() {
- grep '^#/' "${MO_ORIGINAL_COMMAND}" | cut -c 4-
- echo ""
- echo "MO_VERSION=$MO_VERSION"
+mo::getContentDelimiter() {
+ local moResult moTemp moOpen moClose
+
+ mo::debug "Getting content for delimiter"
+ moResult=""
+ mo::getContentTrim moTemp
+ moResult="$moResult$moTemp"
+ mo::chomp moOpen "$MO_UNPARSED"
+ MO_UNPARSED="${MO_UNPARSED:${#moOpen}}"
+ moResult="$moResult$moOpen"
+ mo::getContentTrim moTemp
+ moResult="$moResult$moTemp"
+ mo::chomp moClose "${MO_UNPARSED%%="$MO_CLOSE_DELIMITER"*}"
+ MO_UNPARSED="${MO_UNPARSED:${#moClose}}"
+ moResult="$moResult$moClose"
+ mo::getContentTrim moTemp
+ moResult="$moResult$moTemp"
+ MO_OPEN_DELIMITER="$moOpen"
+ MO_CLOSE_DELIMITER="$moClose"
+
+ local "$1" && mo::indirect "$1" "$moResult"
+}
+
+
+# Get the content up to and including a close tag. First two non-whitespace
+# tokens become the new open and close tag.
+#
+# $1 - Destination variable, an array
+# $2 - Terminator string
+#
+# The array contents:
+# [0] The raw content within the tag
+# [1] The parsed tokens as a single string
+#
+# Returns nothing.
+mo::getContentWithinTag() {
+ local moUnparsed moTokens
+
+ moUnparsed=${MO_UNPARSED}
+ mo::tokenizeTagContents moTokens "$MO_CLOSE_DELIMITER"
+ MO_UNPARSED=${MO_UNPARSED#"$MO_CLOSE_DELIMITER"}
+ mo::tokensToString moTokensString "${moTokens[@]:1}"
+ moParsed=${moUnparsed:0:$((${#moUnparsed} - ${#MO_UNPARSED}))}
+
+ local "$1" && mo::indirectArray "$1" "$moParsed" "$moTokensString"
+}
+
+
+# Internal: Parse MO_UNPARSED and retrieve the content within the tag
+# delimiters. Converts everything into an array of string values.
+#
+# $1 - Destination variable for the array of contents.
+# $2 - Stop processing when this content is found.
+#
+# The list of tokens are in RPN form. The first item in the resulting array is
+# the number of actual tokens (after combining command tokens) in the list.
+#
+# Given: a 'bc' "de\"\n" (f {g 'h'})
+# Result: ([0]=4 [1]=NAME [2]=a [3]=VALUE [4]=bc [5]=VALUE [6]=$'de\"\n'
+# [7]=NAME [8]=f [9]=NAME [10]=g [11]=VALUE [12]=h
+# [13]=BRACE [14]=2 [15]=PAREN [16]=2
+#
+# Returns nothing
+mo::tokenizeTagContents() {
+ local moResult moTerminator moTemp moUnparsedOriginal moTokenCount
+
+ moTerminator=$2
+ moResult=()
+ moUnparsedOriginal=$MO_UNPARSED
+ moTokenCount=0
+ mo::debug "Tokenizing tag contents until terminator: $moTerminator"
+
+ while true; do
+ mo::trimUnparsed
+
+ case "$MO_UNPARSED" in
+ "")
+ mo::errorNear "Did not find matching terminator: $moTerminator" "$moUnparsedOriginal"
+ ;;
+
+ "$moTerminator"*)
+ mo::debug "Found terminator"
+ local "$1" && mo::indirectArray "$1" "$moTokenCount" ${moResult[@]+"${moResult[@]}"}
+ return
+ ;;
+
+ '('*)
+ #: Do not tokenize the open paren - treat this as RPL
+ MO_UNPARSED=${MO_UNPARSED:1}
+ mo::tokenizeTagContents moTemp ')'
+ moResult=(${moResult[@]+"${moResult[@]}"} "${moTemp[@]:1}" PAREN "${moTemp[0]}")
+ MO_UNPARSED=${MO_UNPARSED:1}
+ ;;
+
+ '{'*)
+ #: Do not tokenize the open brace - treat this as RPL
+ MO_UNPARSED=${MO_UNPARSED:1}
+ mo::tokenizeTagContents moTemp '}'
+ moResult=(${moResult[@]+"${moResult[@]}"} "${moTemp[@]:1}" BRACE "${moTemp[0]}")
+ MO_UNPARSED=${MO_UNPARSED:1}
+ ;;
+
+ ')'* | '}'*)
+ mo::errorNear "Unbalanced closing parenthesis or brace" "$MO_UNPARSED"
+ ;;
+
+ "'"*)
+ mo::tokenizeTagContentsSingleQuote moTemp
+ moResult=(${moResult[@]+"${moResult[@]}"} "${moTemp[@]}")
+ ;;
+
+ '"'*)
+ mo::tokenizeTagContentsDoubleQuote moTemp
+ moResult=(${moResult[@]+"${moResult[@]}"} "${moTemp[@]}")
+ ;;
+
+ *)
+ mo::tokenizeTagContentsName moTemp
+ moResult=(${moResult[@]+"${moResult[@]}"} "${moTemp[@]}")
+ ;;
+ esac
+
+ mo::debug "Got chunk: ${moTemp[0]} ${moTemp[1]}"
+ moTokenCount=$((moTokenCount + 1))
+ done
+}
+
+
+# Internal: Get the contents of a variable name.
+#
+# $1 - Destination variable name for the token list (array of strings)
+#
+# Returns nothing
+mo::tokenizeTagContentsName() {
+ local moTemp
+
+ mo::chomp moTemp "${MO_UNPARSED%%"$MO_CLOSE_DELIMITER"*}"
+ moTemp=${moTemp%%(*}
+ moTemp=${moTemp%%)*}
+ moTemp=${moTemp%%\{*}
+ moTemp=${moTemp%%\}*}
+ MO_UNPARSED=${MO_UNPARSED:${#moTemp}}
+ mo::trimUnparsed
+ mo::debug "Parsed default token: $moTemp"
+
+ local "$1" && mo::indirectArray "$1" "NAME" "$moTemp"
+}
+
+
+# Internal: Get the contents of a tag in double quotes. Parses the backslash
+# sequences.
+#
+# $1 - Destination variable name for the token list (array of strings)
+#
+# Returns nothing.
+mo::tokenizeTagContentsDoubleQuote() {
+ local moResult moUnparsedOriginal
+
+ moUnparsedOriginal=$MO_UNPARSED
+ MO_UNPARSED=${MO_UNPARSED:1}
+ moResult=
+ mo::debug "Getting double quoted tag contents"
+
+ while true; do
+ if [[ -z "$MO_UNPARSED" ]]; then
+ mo::errorNear "Unbalanced double quote" "$moUnparsedOriginal"
+ fi
+
+ case "$MO_UNPARSED" in
+ '"'*)
+ MO_UNPARSED=${MO_UNPARSED:1}
+ local "$1" && mo::indirectArray "$1" "VALUE" "$moResult"
+ return
+ ;;
+
+ \\b*)
+ moResult="$moResult"$'\b'
+ MO_UNPARSED=${MO_UNPARSED:2}
+ ;;
+
+ \\e*)
+ #: Note, \e is ESC, but in Bash $'\E' is ESC.
+ moResult="$moResult"$'\E'
+ MO_UNPARSED=${MO_UNPARSED:2}
+ ;;
+
+ \\f*)
+ moResult="$moResult"$'\f'
+ MO_UNPARSED=${MO_UNPARSED:2}
+ ;;
+
+ \\n*)
+ moResult="$moResult"$'\n'
+ MO_UNPARSED=${MO_UNPARSED:2}
+ ;;
+
+ \\r*)
+ moResult="$moResult"$'\r'
+ MO_UNPARSED=${MO_UNPARSED:2}
+ ;;
+
+ \\t*)
+ moResult="$moResult"$'\t'
+ MO_UNPARSED=${MO_UNPARSED:2}
+ ;;
+
+ \\v*)
+ moResult="$moResult"$'\v'
+ MO_UNPARSED=${MO_UNPARSED:2}
+ ;;
+
+ \\*)
+ moResult="$moResult${MO_UNPARSED:1:1}"
+ MO_UNPARSED=${MO_UNPARSED:2}
+ ;;
+
+ *)
+ moResult="$moResult${MO_UNPARSED:0:1}"
+ MO_UNPARSED=${MO_UNPARSED:1}
+ ;;
+ esac
+ done
+}
+
+
+# Internal: Get the contents of a tag in single quotes. Only gets the raw
+# value.
+#
+# $1 - Destination variable name for the token list (array of strings)
+#
+# Returns nothing.
+mo::tokenizeTagContentsSingleQuote() {
+ local moResult moUnparsedOriginal
+
+ moUnparsedOriginal=$MO_UNPARSED
+ MO_UNPARSED=${MO_UNPARSED:1}
+ moResult=
+ mo::debug "Getting single quoted tag contents"
+
+ while true; do
+ if [[ -z "$MO_UNPARSED" ]]; then
+ mo::errorNear "Unbalanced single quote" "$moUnparsedOriginal"
+ fi
+
+ case "$MO_UNPARSED" in
+ "'"*)
+ MO_UNPARSED=${MO_UNPARSED:1}
+ local "$1" && mo::indirectArray "$1" VALUE "$moResult"
+ return
+ ;;
+
+ *)
+ moResult="$moResult${MO_UNPARSED:0:1}"
+ MO_UNPARSED=${MO_UNPARSED:1}
+ ;;
+ esac
+ done
}
# Save the original command's path for usage later
MO_ORIGINAL_COMMAND="$(cd "${BASH_SOURCE[0]%/*}" || exit 1; pwd)/${BASH_SOURCE[0]##*/}"
-MO_VERSION="2.2.0"
+MO_VERSION="3.0.6"
# If sourced, load all functions.
# If executed, perform the actions as expected.
diff --git a/tests/no-pipe/__index.html b/tests/no-pipe/__index.html
new file mode 100644
index 0000000..e69de29
diff --git a/tests/no-pipe/entry.html b/tests/no-pipe/entry.html
new file mode 100644
index 0000000..e69de29
diff --git a/tests/no-pipe/index.html b/tests/no-pipe/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/tests/no-pipe/posts/0-invalid-|-pipe.md b/tests/no-pipe/posts/0-invalid-|-pipe.md
new file mode 100644
index 0000000..e69de29
diff --git a/tests/tags/__index.html b/tests/tags/__index.html
new file mode 100644
index 0000000..e69de29
diff --git a/tests/tags/__tag.html b/tests/tags/__tag.html
new file mode 100644
index 0000000..a2a8d90
--- /dev/null
+++ b/tests/tags/__tag.html
@@ -0,0 +1,4 @@
+
+ {{title}}
+ Last modified {{date}}
+
diff --git a/tests/tags/drafts/3-my-draft.md b/tests/tags/drafts/3-my-draft.md
new file mode 100644
index 0000000..ed26c3b
--- /dev/null
+++ b/tests/tags/drafts/3-my-draft.md
@@ -0,0 +1,4 @@
+# Draft
+tags: draft
+
+Words go here.
diff --git a/tests/tags/entry.html b/tests/tags/entry.html
new file mode 100644
index 0000000..7604f8a
--- /dev/null
+++ b/tests/tags/entry.html
@@ -0,0 +1,13 @@
+{{title}}
+
+{{body}}
+
+{{#tags}}
+ - {{.}}
+{{/tags}}
+
+{{#ALL_TAGS}}
+- {{@key}} {{.}}
+{{/ALL_TAGS}}
+
+{{#tags.0}}has tags without the dedicated key{{/tags.0}}
diff --git a/tests/tags/index.html b/tests/tags/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/tests/tags/page.html b/tests/tags/page.html
new file mode 100644
index 0000000..e69de29
diff --git a/tests/tags/pages/my-page.md b/tests/tags/pages/my-page.md
new file mode 100644
index 0000000..bc3e45b
--- /dev/null
+++ b/tests/tags/pages/my-page.md
@@ -0,0 +1,3 @@
+# My Page
+
+Some words.
diff --git a/tests/tags/posts/0-test.md b/tests/tags/posts/0-test.md
new file mode 100644
index 0000000..d38b8d9
--- /dev/null
+++ b/tests/tags/posts/0-test.md
@@ -0,0 +1,9 @@
+# test 1
+tags: one, two, three, four and five
+
+some words
+
+go here
+
+
+yay
diff --git a/tests/tags/posts/1-no-tags.md b/tests/tags/posts/1-no-tags.md
new file mode 100644
index 0000000..d363cc0
--- /dev/null
+++ b/tests/tags/posts/1-no-tags.md
@@ -0,0 +1,3 @@
+# title
+
+no tags, just body
diff --git a/tests/tags/posts/2-more-tags.md b/tests/tags/posts/2-more-tags.md
new file mode 100644
index 0000000..9cbd0d8
--- /dev/null
+++ b/tests/tags/posts/2-more-tags.md
@@ -0,0 +1,4 @@
+# test 2
+tags: one, three, four and five, plus-one
+
+and some words
diff --git a/tests/tags/sitemap.xml b/tests/tags/sitemap.xml
new file mode 100644
index 0000000..4b2f89b
--- /dev/null
+++ b/tests/tags/sitemap.xml
@@ -0,0 +1,3 @@
+{{#slugs}}
+- {{.}}
+{{/slugs}}
diff --git a/tests/tags/tag.html b/tests/tags/tag.html
new file mode 100644
index 0000000..8cd030f
--- /dev/null
+++ b/tests/tags/tag.html
@@ -0,0 +1,3 @@
+{{tag}}
+
+{{__tag}}
diff --git a/tests/tags/tags.html b/tests/tags/tags.html
new file mode 100644
index 0000000..7ad7dcb
--- /dev/null
+++ b/tests/tags/tags.html
@@ -0,0 +1,5 @@
+
+ {{#all_tags}}
+ - {{@key}} ({{.}})
+ {{/all_tags}}
+
diff --git a/tests/test.bats b/tests/test.bats
index ebb24a4..853a456 100644
--- a/tests/test.bats
+++ b/tests/test.bats
@@ -133,11 +133,35 @@
rm -rf tests/.env-defaults/build
[[ ! -d tests/.env-defaults/build ]]
- # run again, this time supplying a runtime defiition of the env var
+ # run again, this time supplying a runtime definition of the env var
FOO=baz run ./bic tests/.env-defaults
[[ "${status}" == 0 ]]
run cat tests/.env-defaults/build/index.html
[[ "${lines[0]}" == "baz" ]]
rm -rf tests/.env-defaults/build
[[ ! -d tests/.env-defaults/build ]]
-}
\ No newline at end of file
+}
+
+@test "bic doesn't allow posts with a pipe '|' in the filename" {
+ run ./bic tests/no-pipe
+ [[ "${status}" != 0 ]]
+ [[ "${output}" == *"cannot contain a pipe literal"* ]]
+ rm -rf tests/no-pipe/build
+ [[ ! -d tests/no-pipe/build ]]
+}
+
+@test "bic does tags" {
+ run ./bic tests/tags
+ [[ "${status}" == 0 ]]
+ [[ -f tests/tags/build/tags.html ]]
+ [[ -f tests/tags/build/tags/one.html ]]
+ [[ ! -f tests/tags/build/tags/draft.html ]]
+ run cat tests/tags/build/sitemap.xml
+ [[ "${output}" == *"tags"* ]]
+ [[ "${output}" == *"tags/one"* ]]
+ [[ "${output}" != *"tags/draft"* ]]
+ run cat tests/tags/build/test.html
+ [[ "${output}" == *"has tags without the dedicated key"* ]]
+ rm -rf tests/tags/build
+ [[ ! -d tests/tags/build ]]
+}