- Added getCustomVariable(name) and concat(...) function to CJSON
- Generalized up [docgen.insertXxx ...] tag parsing a bit
- Added unfinished implementation of [docgen.insertOutput ...] directive. This will be used to simplify inserting the output of "programs" into the documentation.
diff --git a/freemarker-docgen-core/src/main/java/org/freemarker/docgen/core/BashCommandLineArgsParser.java b/freemarker-docgen-core/src/main/java/org/freemarker/docgen/core/BashCommandLineArgsParser.java
new file mode 100644
index 0000000..c464b34
--- /dev/null
+++ b/freemarker-docgen-core/src/main/java/org/freemarker/docgen/core/BashCommandLineArgsParser.java
@@ -0,0 +1,98 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.freemarker.docgen.core;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Splits a bash command call to a list of arguments. Quotation and escaping is resolved in the returned arguments.
+ */
+public class BashCommandLineArgsParser {
+    private final String src;
+    private int pos;
+
+    public BashCommandLineArgsParser(String src) {
+        this.src = src;
+    }
+
+    public static List<String> parse(String s) {
+        return new BashCommandLineArgsParser(s).parse();
+    }
+
+    private List<String> parse() {
+        List<String> args = new ArrayList<>();
+        String arg;
+        while ((arg = skipWSAndFetchArg()) != null) {
+            args.add(arg);
+        }
+        return args;
+    }
+
+    private String skipWSAndFetchArg() {
+        skipWS();
+        return fetchArg();
+    }
+
+    private String fetchArg() {
+        StringBuilder arg = new StringBuilder();
+        int startPos = pos;
+        char openedQuote = 0;
+        boolean escaped = false;
+        while (pos < src.length()) {
+            char c = src.charAt(pos);
+            if (escaped) {
+                if (openedQuote == '"' && !(c == '"' || c == '\\' || c == '$')) {
+                    arg.append('\\');
+                }
+                arg.append(c);
+                escaped = false;
+            } else {
+                if (c == '"' || c == '\'') {
+                    if (openedQuote == 0) {
+                        openedQuote = c;
+                    } else if (openedQuote == c) {
+                        openedQuote = 0;
+                    } else {
+                        arg.append(c);
+                    }
+                } else if (c == '\\' && openedQuote != '\'') {
+                    escaped = true;
+                } else if (openedQuote == 0 && isWS(c)) {
+                    break;
+                } else {
+                    arg.append(c);
+                }
+            }
+            pos++;
+        }
+        return startPos != pos ? arg.toString() : null;
+    }
+
+    private void skipWS() {
+        while (pos < src.length() && isWS(src.charAt(pos))) {
+            pos++;
+        }
+    }
+
+    private boolean isWS(char c) {
+        return c == ' ' || c == '\n' || c == '\r' || c == '\t';
+    }
+}
diff --git a/freemarker-docgen-core/src/main/java/org/freemarker/docgen/core/CJSONInterpreter.java b/freemarker-docgen-core/src/main/java/org/freemarker/docgen/core/CJSONInterpreter.java
index 543aea6..fc43e10 100644
--- a/freemarker-docgen-core/src/main/java/org/freemarker/docgen/core/CJSONInterpreter.java
+++ b/freemarker-docgen-core/src/main/java/org/freemarker/docgen/core/CJSONInterpreter.java
@@ -329,22 +329,23 @@
         }
     }
 
-    /**
-     * Same as <code>evalAsMap(textFromUTF8File, null, false, null)</code>.
-     * The file must use UTF-8 encoding. Initial BOM is allowed.
-     * @throws IOException 
-     * @see #evalAsMap(String, EvaluationEnvironment, boolean, String)
-     */
     public static Map<String, Object> evalAsMap(File f)
             throws EvaluationException, IOException {
+        return evalAsMap(f, null, false);
+    }
+
+    /**
+     * Same as <code>evalAsMap(textFromUTF8File, null, false, null)</code>.
+     * Loads the file with {@link #loadCJSONFile}.
+     * @see #evalAsMap(String, EvaluationEnvironment, boolean, String)
+     */
+    public static Map<String, Object> evalAsMap(File f, EvaluationEnvironment ee, boolean forceStringValues)
+            throws EvaluationException, IOException {
         String s;
-        InputStream in = new FileInputStream(f);
-        try {
+        try (InputStream in = new FileInputStream(f)) {
             s = loadCJSONFile(in, f.getAbsolutePath());
-        } finally {
-            in.close();
         }
-        return evalAsMap(s, f.getAbsolutePath());
+        return evalAsMap(s, ee, forceStringValues, f.getAbsolutePath());
     }
     
     /**
diff --git a/freemarker-docgen-core/src/main/java/org/freemarker/docgen/core/DocgenSubstitutionTemplateException.java b/freemarker-docgen-core/src/main/java/org/freemarker/docgen/core/DocgenSubstitutionTemplateException.java
deleted file mode 100644
index 4c1e805..0000000
--- a/freemarker-docgen-core/src/main/java/org/freemarker/docgen/core/DocgenSubstitutionTemplateException.java
+++ /dev/null
@@ -1,37 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements.  See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership.  The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License.  You may obtain a copy of the License at
- *
- *   http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied.  See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-
-package org.freemarker.docgen.core;
-
-import freemarker.core.Environment;
-import freemarker.template.TemplateException;
-
-/**
- * Exception thrown by docgen tag-s that are inside the XML text. As such, it's treated as the mistake of the document
- * author (as opposed to an internal error).
- */
-final class DocgenSubstitutionTemplateException extends TemplateException {
-    public DocgenSubstitutionTemplateException(String description, Environment env) {
-        super(description, env);
-    }
-
-    public DocgenSubstitutionTemplateException(String description, Exception cause, Environment env) {
-        super(description, cause, env);
-    }
-}
diff --git a/freemarker-docgen-core/src/main/java/org/freemarker/docgen/core/PrintTextWithDocgenSubstitutionsDirective.java b/freemarker-docgen-core/src/main/java/org/freemarker/docgen/core/PrintTextWithDocgenSubstitutionsDirective.java
index c8110f0..55adcbf 100644
--- a/freemarker-docgen-core/src/main/java/org/freemarker/docgen/core/PrintTextWithDocgenSubstitutionsDirective.java
+++ b/freemarker-docgen-core/src/main/java/org/freemarker/docgen/core/PrintTextWithDocgenSubstitutionsDirective.java
@@ -21,19 +21,16 @@
 
 import java.io.IOException;
 import java.io.InputStream;
-import java.io.InputStreamReader;
-import java.io.Reader;
 import java.io.Writer;
 import java.nio.charset.Charset;
 import java.nio.charset.StandardCharsets;
 import java.nio.charset.UnsupportedCharsetException;
 import java.nio.file.Files;
 import java.nio.file.Path;
-import java.util.HashMap;
 import java.util.HashSet;
+import java.util.List;
 import java.util.Map;
 import java.util.Objects;
-import java.util.Optional;
 import java.util.Set;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
@@ -65,7 +62,9 @@
     private static final String PARAM_TEXT = "text";
     private static final String DOCGEN_TAG_START = "[docgen";
     private static final String DOCGEN_TAG_END = "]";
+    private static final String DOCGEN_END_TAG_START = "[/docgen";
     private static final String INSERT_FILE = "insertFile";
+    private static final String INSERT_OUTPUT = "insertOutput";
 
     private final Transform transform;
 
@@ -144,40 +143,13 @@
 
                     insertCustomVariable(customVarName);
                 } else if (INSERT_FILE.equals(subvarName)) {
-                    skipWS();
-                    String pathArg = fetchRequiredString();
-                    String charsetArg = null;
-                    String fromArg = null;
-                    String toArg = null;
-                    String toIfPresentArg = null;
-                    Set<String> paramNamesSeen = new HashSet<>();
-                    while (skipWS()) {
-                        String paramName = fetchOptionalVariableName();
-                        skipRequiredToken("=");
-                        String paramValue = StringEscapeUtils.unescapeXml(fetchRequiredString());
-                        if (!paramNamesSeen.add(paramName)) {
-                            throw new TemplateException(
-                                    "Duplicate " + StringUtil.jQuote(INSERT_FILE)
-                                            +  " parameter " + StringUtil.jQuote(paramName) + ".", env);
-                        }
-                        if (paramName.equals("charset")) {
-                            charsetArg = paramValue;
-                        } else if (paramName.equals("from")) {
-                            fromArg = paramValue;
-                        } else if (paramName.equals("to")) {
-                            toArg = paramValue;
-                        } else if (paramName.equals("toIfPresent")) {
-                            toIfPresentArg = paramValue;
-                        } else {
-                            throw new TemplateException(
-                                    "Unsupported " + StringUtil.jQuote(INSERT_FILE)
-                                            +  " parameter " + StringUtil.jQuote(paramName) + ".", env);
-                        }
-                    }
-                    skipRequiredToken(DOCGEN_TAG_END);
+                    InsertDirectiveArgs args = fetchInsertDirectiveArgs(subvarName, true, true, false);
                     lastUnprintedIdx = cursor;
-
-                    insertFile(pathArg, charsetArg, fromArg, toArg, toIfPresentArg);
+                    insertFile(args);
+                } else if (INSERT_OUTPUT.equals(subvarName)) {
+                    InsertDirectiveArgs args = fetchInsertDirectiveArgs(subvarName, false, false, true);
+                    lastUnprintedIdx = cursor;
+                    insertOutput(args);
                 } else {
                     throw new TemplateException(
                             "Unsupported docgen subvariable " + StringUtil.jQuote(subvarName) + ".", env);
@@ -239,11 +211,9 @@
             }
         }
 
-        private void insertFile(String pathArg, String charsetArg, String fromArg,
-                String toArg, String toIfPresentArg)
-                throws TemplateException, IOException {
-            int slashIndex = pathArg.indexOf("/");
-            String symbolicNameStep = slashIndex != -1 ? pathArg.substring(0, slashIndex) : pathArg;
+        private void insertFile(InsertDirectiveArgs args) throws TemplateException, IOException {
+            int slashIndex = args.path.indexOf("/");
+            String symbolicNameStep = slashIndex != -1 ? args.path.substring(0, slashIndex) : args.path;
             if (!symbolicNameStep.startsWith("@") || symbolicNameStep.length() < 2) {
                 throw newErrorInDocgenTag("Path argument must start with @<symbolicName>/, "
                         + " where <symbolicName> is in " + transform.getInsertableFiles().keySet() + ".");
@@ -257,7 +227,7 @@
             }
             symbolicNamePath = symbolicNamePath.toAbsolutePath().normalize();
             Path resolvedFilePath = slashIndex != -1
-                    ? symbolicNamePath.resolve(pathArg.substring(slashIndex + 1))
+                    ? symbolicNamePath.resolve(args.path.substring(slashIndex + 1))
                     : symbolicNamePath;
             resolvedFilePath = resolvedFilePath.normalize();
             if (!resolvedFilePath.startsWith(symbolicNamePath)) {
@@ -270,11 +240,11 @@
             }
 
             Charset charset;
-            if (charsetArg != null) {
+            if (args.charset != null) {
                 try {
-                    charset = Charset.forName(charsetArg);
+                    charset = Charset.forName(args.charset);
                 } catch (UnsupportedCharsetException e) {
-                    throw newErrorInDocgenTag("Unsupported charset: " + charsetArg);
+                    throw newErrorInDocgenTag("Unsupported charset: " + args.charset);
                 }
             } else {
                 charset = StandardCharsets.UTF_8;
@@ -287,70 +257,29 @@
                     fileContent = removeFTLCopyrightComment(fileContent);
                 }
 
-                if (fromArg != null) {
-                    boolean optional;
-                    String fromArgCleaned;
-                    if (fromArg.startsWith("?")) {
-                        optional = true;
-                        fromArgCleaned = fromArg.substring(1);
-                    } else {
-                        optional = false;
-                        fromArgCleaned = fromArg;
-                    }
-                    Pattern from;
-                    try {
-                        from = Pattern.compile(fromArgCleaned, Pattern.MULTILINE);
-                    } catch (PatternSyntaxException e) {
-                        throw newErrorInDocgenTag("Invalid regular expression: " + fromArgCleaned);
-                    }
-                    Matcher matcher = from.matcher(fileContent);
+                if (args.from != null) {
+                    Matcher matcher = args.from.matcher(fileContent);
                     if (matcher.find()) {
                         String remaining = fileContent.substring(matcher.start());
                         fileContent = "[\u2026]"
                                 + (remaining.startsWith("\n") || remaining.startsWith("\r") ? "" : "\n")
                                 + remaining;
-                    } else {
-                        if (!optional) {
-                            throw newErrorInDocgenTag(
-                                    "Regular expression has no match in the file content: " + fromArg);
-                        }
+                    } else if (!args.fromOptional) {
+                        throw newErrorInDocgenTag(
+                                "\"from\" regular expression has no match in the file content: " + args.from);
                     }
                 }
 
-                String toStr;
-                boolean toPresenceOptional;
-                if (toArg != null) {
-                    if (toIfPresentArg != null) {
-                        throw newErrorInDocgenTag(
-                                "Can't use both \"to\" and \"toIfPresent\" argument.");
-                    }
-                    toStr = toArg;
-                    toPresenceOptional = false;
-                } else if (toIfPresentArg != null) {
-                    toStr = toIfPresentArg;
-                    toPresenceOptional = true;
-                } else {
-                    toStr = null;
-                    toPresenceOptional = false;
-                }
-                if (toStr != null) {
-                    Pattern to;
-                    try {
-                        to = Pattern.compile(toStr, Pattern.MULTILINE);
-                    } catch (PatternSyntaxException e) {
-                        throw newErrorInDocgenTag("Invalid regular expression: " + toStr);
-                    }
-                    Matcher matcher = to.matcher(fileContent);
+                if (args.to != null) {
+                    Matcher matcher = args.to.matcher(fileContent);
                     if (matcher.find()) {
                         String remaining = fileContent.substring(0, matcher.start());
                         fileContent = remaining
                                 + (remaining.endsWith("\n") || remaining.endsWith("\r") ? "" : "\n")
                                 + "[\u2026]";
-                    } else {
-                        if (!toPresenceOptional) {
-                            throw newErrorInDocgenTag(
-                                    "Regular expression has no match in the file content: " + toStr);
-                        }
+                    } else if (!args.toOptional) {
+                        throw newErrorInDocgenTag(
+                                "\"to\" regular expression has no match in the file content: " + args.to);
                     }
                 }
 
@@ -358,6 +287,28 @@
             }
         }
 
+        private void insertOutput(InsertDirectiveArgs args) throws TemplateException, IOException {
+            List<String> splitCmdLine = BashCommandLineArgsParser.parse(args.body);
+            if (splitCmdLine.isEmpty()) {
+                throw newErrorInDocgenTag("Command to execute was empty");
+            }
+            String cmdKey = splitCmdLine.get(0);
+            List<String> cmdArgs = splitCmdLine.subList(1, splitCmdLine.size());
+            Map<String, Transform.InsertableOutputCommandProperties> cmdPropsMap =
+                    transform.getInsertableOutputCommands();
+            Transform.InsertableOutputCommandProperties cmdProps = cmdPropsMap.get(cmdKey);
+            if (cmdProps == null) {
+                throw newErrorInDocgenTag(
+                        "The " + Transform.SETTING_INSERTABLE_OUTPUT_COMMANDS
+                                + " configuration setting doesn't have entry with key " + StringUtil.jQuote(cmdKey)
+                                + ". "
+                                + (cmdPropsMap.isEmpty()
+                                        ? "That setting is empty."
+                                        : "It has these keys: " + String.join(", ", cmdPropsMap.keySet())));
+            }
+            HTMLOutputFormat.INSTANCE.output("!!T\n" + cmdProps + "\n" + cmdArgs, out);
+        }
+
         private TemplateException newFormattingFailedException(String customVarName, TemplateValueFormatException e) {
             return new TemplateException(
                     "Formatting failed for Docgen custom variable "
@@ -365,8 +316,8 @@
                     e, env);
         }
 
-        private int findNextDocgenTagStart(int lastUnprintedIdx) {
-            int startIdx = text.indexOf(DOCGEN_TAG_START, lastUnprintedIdx);
+        private int findNextDocgenTagStart(int fromIndex) {
+            int startIdx = text.indexOf(DOCGEN_TAG_START, fromIndex);
             if (startIdx == -1) {
                 return -1;
             }
@@ -378,6 +329,25 @@
             return -1;
         }
 
+        private int findNextDocgenEndTag(int fromIndex) {
+            int startIdx = text.indexOf(DOCGEN_END_TAG_START, fromIndex);
+            if (startIdx == -1) {
+                return -1;
+            }
+            int afterTagStartIdx = startIdx + DOCGEN_END_TAG_START.length();
+            if (afterTagStartIdx < text.length()
+                    && !Character.isJavaIdentifierPart(text.charAt(afterTagStartIdx))) {
+                return startIdx;
+            }
+            return -1;
+        }
+
+        private void skipRequiredWS() throws DocgenTagException {
+            if (!skipWS()) {
+                throw newUnexpectedTokenException("whitespace", env);
+            }
+        }
+
         private boolean skipWS() {
             boolean found = false;
             while (cursor < text.length()) {
@@ -454,36 +424,139 @@
             int stringStartIdx = cursor;
             while (cursor < text.length() && charAt(cursor) != quoteChar) {
                 if (!rawString && charAt(cursor) == '\\') {
-                    throw new DocgenSubstitutionTemplateException(
+                    throw new DocgenTagException(
                             "Backslash is currently not supported in string literal in Docgen tags, "
                                     + "except in raw strings (like r\"regular\\s+expression\").", env);
                 }
                 cursor++;
             }
             if (charAt(cursor) != quoteChar) {
-                throw new DocgenSubstitutionTemplateException("Unclosed string literal in a Docgen tag.", env);
+                throw new DocgenTagException("Unclosed string literal in a Docgen tag.", env);
             }
             String result = text.substring(stringStartIdx, cursor);
             cursor++;
             return result;
         }
 
+        private boolean fetchRequiredBoolean() throws TemplateException {
+            Boolean result = fetchOptionalBoolean();
+            if (result == null) {
+                throw newUnexpectedTokenException("boolean", env);
+            }
+            return result;
+        }
+
+        private Boolean fetchOptionalBoolean() throws DocgenTagException {
+            String name = fetchOptionalVariableName();
+            if (name == null) {
+                return null;
+            }
+            if (name.equals("true")) {
+                return true;
+            } else if (name.equals("false")) {
+                return false;
+            } else {
+                throw new DocgenTagException("true or false", env);
+            }
+        }
+
+
         private char charAt(int index) {
             return index < text.length() ? text.charAt(index) : 0;
         }
 
-        private TemplateException newUnexpectedTokenException(String expectedTokenDesc, Environment env) {
-            return new DocgenSubstitutionTemplateException(
+        private DocgenTagException newUnexpectedTokenException(String expectedTokenDesc, Environment env) {
+            return new DocgenTagException(
                     "Expected " + expectedTokenDesc + " after this: " + text.substring(lastDocgenTagStart, cursor),
                     env);
         }
 
         private TemplateException newErrorInDocgenTag(String errorDetail) {
-            return new DocgenSubstitutionTemplateException(
+            return new DocgenTagException(
                     "\nError in docgen tag: " + text.substring(lastDocgenTagStart, cursor) + "\n" + errorDetail,
                     env);
 
         }
+
+        private InsertDirectiveArgs fetchInsertDirectiveArgs(
+                String subvarName, boolean hasPath, boolean allowCharsetArg, boolean hasBodyArg) throws
+                TemplateException {
+            InsertDirectiveArgs args = new InsertDirectiveArgs();
+            args.toOptional = true;
+
+            if (hasPath) {
+                skipWS();
+                args.path = fetchRequiredString();
+            }
+
+            Set<String> paramNamesSeen = new HashSet<>();
+            String paramName;
+            while (skipWS() && (paramName = fetchOptionalVariableName()) != null) {
+                skipRequiredToken("=");
+                if (!paramNamesSeen.add(paramName)) {
+                    throw new DocgenTagException(
+                            "Duplicate docgen." + subvarName +  " parameter " + StringUtil.jQuote(paramName) + ".",
+                            env);
+                }
+                if (allowCharsetArg && paramName.equals("charset")) {
+                    args.charset = StringEscapeUtils.unescapeXml(fetchRequiredString());
+                } else if (paramName.equals("from")) {
+                    args.from = parseRegularExpressionParam(paramName, StringEscapeUtils.unescapeXml(fetchRequiredString()));
+                } else if (paramName.equals("to")) {
+                    args.to = parseRegularExpressionParam(paramName, StringEscapeUtils.unescapeXml(fetchRequiredString()));
+                } else if (paramName.equals("fromOptional")) {
+                    args.fromOptional = fetchRequiredBoolean();
+                } else if (paramName.equals("toOptional")) {
+                    args.toOptional = fetchRequiredBoolean();
+                } else {
+                    throw new DocgenTagException(
+                            "Unsupported docgen." + subvarName +  " parameter " + StringUtil.jQuote(paramName) + ".",
+                            env);
+                }
+            }
+
+            skipRequiredToken(DOCGEN_TAG_END);
+            int indexAfterStartTag = cursor;
+
+            if (hasBodyArg) {
+                int endTagIndex = findNextDocgenEndTag(cursor);
+                if (endTagIndex == -1) {
+                    throw new DocgenTagException(
+                            "Missing docgen end-tag after " + DOCGEN_TAG_START + "." + subvarName + " ...]", env);
+                }
+                lastDocgenTagStart = endTagIndex;
+
+                args.body = StringEscapeUtils.unescapeXml(text.substring(indexAfterStartTag, endTagIndex));
+
+                cursor = endTagIndex + DOCGEN_END_TAG_START.length();
+                skipRequiredToken(".");
+                String endSubvarName = fetchRequiredVariableName();
+                if (!endSubvarName.equals(subvarName)) {
+                    throw new DocgenTagException(
+                            "End-tag " + DOCGEN_END_TAG_START + "." + endSubvarName + "] doesn't match "
+                                    + DOCGEN_TAG_START + "." + subvarName + " ...] tag.", env);
+                }
+                skipRequiredToken("]");
+            }
+
+            args.indexAfterDirective = cursor;
+
+            return args;
+        }
+
+        private Pattern parseRegularExpressionParam(String paramName, String paramValue) throws TemplateException {
+            Objects.requireNonNull(paramName);
+            Objects.requireNonNull(paramValue);
+            Pattern parsedParamValue;
+            try {
+                parsedParamValue = Pattern.compile(paramValue, Pattern.MULTILINE);
+            } catch (PatternSyntaxException e) {
+                throw newErrorInDocgenTag("Invalid regular expression for parameter \"" +
+                        paramName + "\": " + paramValue);
+            }
+            return parsedParamValue;
+        }
+
     }
 
     public static String removeFTLCopyrightComment(String ftl) {
@@ -534,4 +607,15 @@
         return ftl.substring(0, commentFirstIdx) + ftl.substring(commentLastIdx + afterCommentNLChars + 1);
     }
 
+    static class InsertDirectiveArgs {
+        private String path;
+        private String charset;
+        private Pattern from;
+        private boolean fromOptional;
+        private Pattern to;
+        private boolean toOptional;
+        private String body;
+        private int indexAfterDirective;
+    }
+
 }
diff --git a/freemarker-docgen-core/src/main/java/org/freemarker/docgen/core/Transform.java b/freemarker-docgen-core/src/main/java/org/freemarker/docgen/core/Transform.java
index 37da9ab..20eaead 100644
--- a/freemarker-docgen-core/src/main/java/org/freemarker/docgen/core/Transform.java
+++ b/freemarker-docgen-core/src/main/java/org/freemarker/docgen/core/Transform.java
@@ -45,7 +45,6 @@
 import java.util.Locale;
 import java.util.Map;
 import java.util.Map.Entry;
-import java.util.Objects;
 import java.util.Set;
 import java.util.SortedMap;
 import java.util.TimeZone;
@@ -59,10 +58,7 @@
 import org.w3c.dom.NodeList;
 import org.xml.sax.SAXException;
 
-import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Sets;
 
 import freemarker.cache.ClassTemplateLoader;
 import freemarker.cache.FileTemplateLoader;
@@ -158,6 +154,19 @@
     static final String SETTING_NUMBERED_SECTIONS = "numberedSections";
     static final String SETTING_CUSTOM_VARIABLES = "customVariables";
     static final String SETTING_INSERTABLE_FILES = "insertableFiles";
+    static final String SETTING_INSERTABLE_OUTPUT_COMMANDS = "insertableOutputCommands";
+    static final String SETTING_INSERTABLE_OUTPUT_COMMADS_CLASS_KEY = "class";
+    static final String SETTING_INSERTABLE_OUTPUT_COMMADS_PREPENDED_ARGUMENTS_KEY = "prependedArguments";
+    static final String SETTING_INSERTABLE_OUTPUT_COMMADS_WORK_DIRECTORY_KEY = "workDirectory";
+    static final Set<String> SETTING_INSERTABLE_OUTPUT_COMMADS_OPTIONAL_KEYS;
+    static final Set<String> SETTING_INSERTABLE_OUTPUT_COMMADS_REQUIRED_KEYS;
+    static {
+        SETTING_INSERTABLE_OUTPUT_COMMADS_REQUIRED_KEYS = new LinkedHashSet<>();
+        SETTING_INSERTABLE_OUTPUT_COMMADS_REQUIRED_KEYS.add(SETTING_INSERTABLE_OUTPUT_COMMADS_CLASS_KEY);
+        SETTING_INSERTABLE_OUTPUT_COMMADS_OPTIONAL_KEYS = new LinkedHashSet<>();
+        SETTING_INSERTABLE_OUTPUT_COMMADS_OPTIONAL_KEYS.add(SETTING_INSERTABLE_OUTPUT_COMMADS_PREPENDED_ARGUMENTS_KEY);
+        SETTING_INSERTABLE_OUTPUT_COMMADS_OPTIONAL_KEYS.add(SETTING_INSERTABLE_OUTPUT_COMMADS_WORK_DIRECTORY_KEY);
+    }
 
     static final String SETTING_VALIDATION_PROGRAMLISTINGS_REQ_ROLE
             = "programlistingsRequireRole";
@@ -428,6 +437,8 @@
     private final Map<String, String> insertableFilesFromSettingsFile = new HashMap<>();
     private final Map<String, String> insertableFilesOverrides = new HashMap<>();
 
+    private final Map<String, InsertableOutputCommandProperties> insertableOutputCommands = new HashMap<>();
+
     private final LinkedHashMap<String, String> tabs = new LinkedHashMap<>();
 
     private final Map<String, Map<String, String>> secondaryTabs = new LinkedHashMap<>();
@@ -537,10 +548,9 @@
         if (cfgFile.exists()) {
             Map<String, Object> cfg;
             try {
-                cfg = CJSONInterpreter.evalAsMap(cfgFile);
+                cfg = CJSONInterpreter.evalAsMap(cfgFile, new DocgenCJSONEvaluationEnvironment(), false);
             } catch (CJSONInterpreter.EvaluationException e) {
-                throw new DocgenException(e.getMessage(),
-                        e.getCause());
+                throw new DocgenException(e.getMessage(), e.getCause());
             }
 
             for (Entry<String, Object> cfgEnt : cfg.entrySet()) {
@@ -607,6 +617,36 @@
                     insertableFilesFromSettingsFile.putAll(
                             // Allow null values in the Map, as the caller can override them.
                             castSettingToMap(settingName, settingValue, String.class, String.class, true));
+                } else if (topSettingName.equals(SETTING_INSERTABLE_OUTPUT_COMMANDS)) {
+                    Map<String, Map<String, Object>> m = castSetting(
+                            settingName, settingValue,
+                            Map.class,
+                            new MapEntryType(String.class, Map.class),
+                            new MapEntryType(
+                                    String.class, SETTING_INSERTABLE_OUTPUT_COMMADS_REQUIRED_KEYS, SETTING_INSERTABLE_OUTPUT_COMMADS_OPTIONAL_KEYS,
+                                    Object.class, false));
+                    for (Entry<String, Map<String, Object>> ent : m.entrySet()) {
+                        String commandKey = ent.getKey();
+                        Map<String, Object> outputCmdProps = ent.getValue();
+                        InsertableOutputCommandProperties commandProps = new InsertableOutputCommandProperties(
+                                castSetting(
+                                        settingName.subKey(commandKey, SETTING_INSERTABLE_OUTPUT_COMMADS_CLASS_KEY),
+                                        outputCmdProps.get(SETTING_INSERTABLE_OUTPUT_COMMADS_CLASS_KEY),
+                                        String.class
+                                ),
+                                castSetting(
+                                        settingName.subKey(commandKey, SETTING_INSERTABLE_OUTPUT_COMMADS_PREPENDED_ARGUMENTS_KEY),
+                                        outputCmdProps.get(SETTING_INSERTABLE_OUTPUT_COMMADS_PREPENDED_ARGUMENTS_KEY),
+                                        List.class
+                                ),
+                                Paths.get(castSetting(
+                                        settingName.subKey(commandKey, SETTING_INSERTABLE_OUTPUT_COMMADS_WORK_DIRECTORY_KEY),
+                                        outputCmdProps.get(SETTING_INSERTABLE_OUTPUT_COMMADS_WORK_DIRECTORY_KEY),
+                                        String.class
+                                ))
+                        );
+                        insertableOutputCommands.put(commandKey, commandProps);
+                    }
                 } else if (topSettingName.equals(SETTING_TABS)) {
                     tabs.putAll(
                             castSettingToMap(settingName, settingValue, String.class, String.class));
@@ -1151,8 +1191,8 @@
     private Map<String, Object> computeCustomVariables() throws DocgenException {
         for (String varName : customVariableOverrides.keySet()) {
             if (!customVariablesFromSettingsFile.containsKey(varName)) {
-                throw new DocgenException("Attempt to set custom variable " + StringUtil.jQuote(varName)
-                        + ", when it was not set in the settings file (" + FILE_SETTINGS + ").");
+                throw new DocgenException("Attempt to override custom variable " + StringUtil.jQuote(varName)
+                        + ", when it was not set in the settings file (" + cfgFile + ").");
             }
         }
 
@@ -2500,7 +2540,11 @@
         return insertableFiles;
     }
 
-    // -------------------------------------------------------------------------
+    public Map<String, InsertableOutputCommandProperties> getInsertableOutputCommands() {
+        return insertableOutputCommands;
+    }
+
+// -------------------------------------------------------------------------
 
     public File getDestinationDirectory() {
         return destDir;
@@ -2734,4 +2778,85 @@
         }
     }
 
+    static class InsertableOutputCommandProperties {
+        private final String mainClassName;
+        private final List<String> prependedArguments;
+        private final Path workDirectory;
+
+        public InsertableOutputCommandProperties(String mainClassName, List<String> prependedArguments, Path workDirectory) {
+            this.mainClassName = mainClassName;
+            this.prependedArguments = prependedArguments;
+            this.workDirectory = workDirectory;
+        }
+
+        @Override
+        public String toString() {
+            return "InsertableOutputCommandProperties{"
+                    + "mainClassName='" + mainClassName + '\''
+                    + ", prependedArguments=" + prependedArguments
+                    + ", workDirectory=" + workDirectory + '}';
+        }
+    }
+
+    @FunctionalInterface
+    interface CJSONFunction {
+        Object run(Transform context, CJSONInterpreter.FunctionCall fc);
+    }
+
+    private static final Map<String, CJSONFunction> CJSON_FUNCTIONS = ImmutableMap.of(
+            "getCustomVariable",
+            (ctx, fc) -> {
+                List<Object> params = fc.getParams();
+                if (params.size() != 1) {
+                    throw new DocgenException(
+                            "CJSON function " + fc.getName() + "(name) "
+                                    + "should have 1 arguments, but had " + params.size() + ".");
+                }
+
+                Object varName = params.get(0);
+                if (!(varName instanceof String)) {
+                    throw new DocgenException(
+                            "CJSON function " + fc.getName() + "(name) "
+                                    + "argument should be a string, but was a(n) "
+                                    + CJSONInterpreter.cjsonTypeNameOfValue(varName) + ".");
+                }
+
+                Object result = ctx.customVariableOverrides.get(varName);
+                if (result == null) {
+                    result = ctx.customVariablesFromSettingsFile.get(varName);
+                }
+                if (result == null) {
+                    throw new DocgenException(
+                            "The custom variable " + StringUtil.jQuote(varName) + " is not set (or was set to null).");
+                }
+                return result;
+            },
+            "concat",
+            (ctx, fc) -> {
+                return fc.getParams().stream()
+                        .filter(it -> it != null)
+                        .map(Object::toString)
+                        .collect(Collectors.joining());
+            }
+    );
+
+    class DocgenCJSONEvaluationEnvironment implements CJSONInterpreter.EvaluationEnvironment {
+        @Override
+        public Object evalFunctionCall(CJSONInterpreter.FunctionCall fc, CJSONInterpreter ip) {
+            String name = fc.getName();
+            CJSONFunction f = CJSON_FUNCTIONS.get(name);
+            if (f == null) {
+                throw new DocgenException("Unknown CJSON function: " + name
+                        + "\nSupported functions are: " + String.join(", ", CJSON_FUNCTIONS.keySet()));
+            }
+            return f.run(Transform.this, fc);
+        }
+
+        @Override
+        public Object notify(CJSONInterpreter.EvaluationEvent event, CJSONInterpreter ip, String name, Object extra) throws
+                Exception {
+            return null;
+        }
+    }
+
 }
diff --git a/freemarker-docgen-core/src/test/java/org/freemarker/docgen/core/BashCommandLineArgsParserTest.java b/freemarker-docgen-core/src/test/java/org/freemarker/docgen/core/BashCommandLineArgsParserTest.java
new file mode 100644
index 0000000..8a9db4a
--- /dev/null
+++ b/freemarker-docgen-core/src/test/java/org/freemarker/docgen/core/BashCommandLineArgsParserTest.java
@@ -0,0 +1,43 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.freemarker.docgen.core;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.Arrays;
+
+import org.junit.jupiter.api.Test;
+
+class BashCommandLineArgsParserTest {
+
+    @Test
+    void parse() {
+        assertEquals(Arrays.asList(), BashCommandLineArgsParser.parse(""));
+        assertEquals(Arrays.asList(), BashCommandLineArgsParser.parse( " "));
+        assertEquals(Arrays.asList("cmd", "1", "2", "3"), BashCommandLineArgsParser.parse("cmd 1\t2\r\n3"));
+        assertEquals(Arrays.asList("1 x", "2 x", "a'bcd"), BashCommandLineArgsParser.parse("'1 x' \"2 x\" a\"'\"b'c'd"));
+        assertEquals(Arrays.asList("abc"), BashCommandLineArgsParser.parse("a\\bc"));
+        assertEquals(Arrays.asList("a\\bc"), BashCommandLineArgsParser.parse("a\\\\bc"));
+        assertEquals(Arrays.asList("a'bc", "d  e"), BashCommandLineArgsParser.parse("a\\'bc d\\ \\ e"));
+        assertEquals(Arrays.asList("a\\b\\c\"$"), BashCommandLineArgsParser.parse("\"a\\b\\\\c\\\"\\$\""));
+        assertEquals(Arrays.asList("a\\b\\\\c"), BashCommandLineArgsParser.parse("'a\\b\\\\c'"));
+    }
+
+}
\ No newline at end of file