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 ]] +}