You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@freemarker.apache.org by dd...@apache.org on 2020/08/09 21:32:13 UTC

[freemarker-docgen] branch master updated: Added [docgen.insertFile "@someSymbolicName/bar.ftl"] and "insertableFiles: { someSymbolicName: '/actual/path/**' }" setting, and removed "customVariablesFromFiles" setting. This approach is much more practical when we have lot of files to insert into the documentation, like lot of example templates.

This is an automated email from the ASF dual-hosted git repository.

ddekany pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/freemarker-docgen.git


The following commit(s) were added to refs/heads/master by this push:
     new 363aab5  Added [docgen.insertFile "@someSymbolicName/bar.ftl"] and "insertableFiles: { someSymbolicName: '/actual/path/**' }" setting, and removed "customVariablesFromFiles" setting. This approach is much more practical when we have lot of files to insert into the documentation, like lot of example templates.
363aab5 is described below

commit 363aab558bc988a74230f73c70a15bca769ad5c5
Author: ddekany <dd...@apache.org>
AuthorDate: Sun Aug 9 23:30:32 2020 +0200

    Added [docgen.insertFile "@someSymbolicName/bar.ftl"] and "insertableFiles: { someSymbolicName: '/actual/path/**' }" setting, and removed "customVariablesFromFiles" setting. This approach is much more practical when we have lot of files to insert into the documentation, like lot of example templates.
---
 .../main/java/org/freemarker/docgen/cli/Main.java  |  14 +
 freemarker-docgen-core/pom.xml                     |   5 +
 .../core/DocgenSubstitutionTemplateException.java  |  37 ++
 .../PrintTextWithDocgenSubstitutionsDirective.java | 402 +++++++++++++++++++++
 .../java/org/freemarker/docgen/core/Transform.java | 142 +++++---
 .../freemarker/docgen/core/templates/footer.ftlh   |   2 +-
 .../docgen/core/templates/node-handlers.ftlh       |   2 +-
 .../org/freemarker/docgen/core/templates/util.ftl  |  19 -
 .../org/freemarker/docgen/maven/TransformMojo.java |  16 +-
 9 files changed, 551 insertions(+), 88 deletions(-)

diff --git a/freemarker-docgen-cli/src/main/java/org/freemarker/docgen/cli/Main.java b/freemarker-docgen-cli/src/main/java/org/freemarker/docgen/cli/Main.java
index 8b33699..efe7bdc 100644
--- a/freemarker-docgen-cli/src/main/java/org/freemarker/docgen/cli/Main.java
+++ b/freemarker-docgen-cli/src/main/java/org/freemarker/docgen/cli/Main.java
@@ -20,6 +20,7 @@ package org.freemarker.docgen.cli;
 
 import java.io.File;
 import java.io.IOException;
+import java.util.Collections;
 import java.util.TimeZone;
 
 import org.freemarker.docgen.core.DocgenException;
@@ -31,6 +32,9 @@ import org.xml.sax.SAXException;
  */
 public final class Main {
 
+    private static final String CUSTOM_VARIABLES_DOT = "customVariables.";
+    private static final String INSERTABLE_FILES_DOT = "insertableFiles.";
+
     // Can't be instantiated
     private Main() {
         // Nop
@@ -64,6 +68,16 @@ public final class Main {
                     tr.setTimeZone(TimeZone.getTimeZone(value));
                 } else if (name.equals("generateEclipseToC")) {
                     tr.setGenerateEclipseToC(parseBoolean(value));
+                } else if (name.startsWith(CUSTOM_VARIABLES_DOT)) {
+                    tr.addCustomVariableOverrides(
+                            Collections.singletonMap(
+                                    name.substring(CUSTOM_VARIABLES_DOT.length()),
+                                    value));
+                } else if (name.startsWith(INSERTABLE_FILES_DOT)) {
+                    tr.addInsertableFileOverrides(
+                            Collections.singletonMap(
+                                    name.substring(INSERTABLE_FILES_DOT.length()),
+                                    value));
                 } else {
                     throw new CommandLineExitException(-1, "Unsupported option: " + name);
                 }
diff --git a/freemarker-docgen-core/pom.xml b/freemarker-docgen-core/pom.xml
index ba6d65e..6e43a71 100644
--- a/freemarker-docgen-core/pom.xml
+++ b/freemarker-docgen-core/pom.xml
@@ -102,6 +102,11 @@
                 </exclusion>
             </exclusions>
         </dependency>
+        <dependency>
+            <groupId>commons-io</groupId>
+            <artifactId>commons-io</artifactId>
+            <version>2.7</version>
+        </dependency>
     </dependencies>
 
     <build>
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
new file mode 100644
index 0000000..4c1e805
--- /dev/null
+++ b/freemarker-docgen-core/src/main/java/org/freemarker/docgen/core/DocgenSubstitutionTemplateException.java
@@ -0,0 +1,37 @@
+/*
+ * 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
new file mode 100644
index 0000000..44dc1f3
--- /dev/null
+++ b/freemarker-docgen-core/src/main/java/org/freemarker/docgen/core/PrintTextWithDocgenSubstitutionsDirective.java
@@ -0,0 +1,402 @@
+/*
+ * 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.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.Reader;
+import java.io.Writer;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Map;
+import java.util.Objects;
+
+import org.apache.commons.io.FilenameUtils;
+import org.apache.commons.io.IOUtils;
+
+import freemarker.core.Environment;
+import freemarker.core.HTMLOutputFormat;
+import freemarker.core.NonStringException;
+import freemarker.core.TemplateHTMLOutputModel;
+import freemarker.core.TemplateValueFormatException;
+import freemarker.template.TemplateBooleanModel;
+import freemarker.template.TemplateDateModel;
+import freemarker.template.TemplateDirectiveBody;
+import freemarker.template.TemplateDirectiveModel;
+import freemarker.template.TemplateException;
+import freemarker.template.TemplateHashModel;
+import freemarker.template.TemplateModel;
+import freemarker.template.TemplateNumberModel;
+import freemarker.template.TemplateScalarModel;
+import freemarker.template.utility.ClassUtil;
+import freemarker.template.utility.StringUtil;
+
+public class PrintTextWithDocgenSubstitutionsDirective implements TemplateDirectiveModel {
+
+    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 INSERT_FILE = "insertFile";
+
+    private final Transform transform;
+
+    public PrintTextWithDocgenSubstitutionsDirective(Transform transform) {
+        this.transform = transform;
+    }
+
+    @Override
+    public void execute(Environment env, Map params, TemplateModel[] loopVars, TemplateDirectiveBody body)
+            throws TemplateException, IOException {
+        String text = null;
+        for (Map.Entry<String, TemplateModel> entry : ((Map<String, TemplateModel>) params).entrySet()) {
+            String paramName = entry.getKey();
+            TemplateModel paramValue = entry.getValue();
+            if (paramValue != null) {
+                if (PARAM_TEXT.equals(paramName)) {
+                    if (!(paramValue instanceof TemplateScalarModel)) {
+                        throw new NonStringException("The \"" + PARAM_TEXT + "\" argument must be a string!", env);
+                    }
+                    text = ((TemplateScalarModel) paramValue).getAsString();
+                } else {
+                    throw new TemplateException("Unsupported parameter: " + StringUtil.jQuote(paramName), env);
+                }
+            }
+        }
+        if (text == null) {
+            throw new TemplateException("Missing required \"" + PARAM_TEXT + "\" argument", env);
+        }
+
+        if (loopVars.length != 0) {
+            throw new TemplateException("Directive doesn't support loop variables", env);
+        }
+
+        if (body != null) {
+            throw new TemplateException("Directive doesn't support nested content", env);
+        }
+
+        new DocgenSubstitutionInterpreter(text, env).execute();
+    }
+
+    private class DocgenSubstitutionInterpreter {
+        private final String text;
+        private final Environment env;
+        private final Writer out;
+        private int cursor;
+        private int lastDocgenTagStart;
+
+        public DocgenSubstitutionInterpreter(String text, Environment env) {
+            this.text = text;
+            this.env = env;
+            this.out = env.getOut();
+        }
+
+        private void execute() throws TemplateException, IOException {
+            int lastUnprintedIdx = 0;
+            parseText: while (true) {
+                cursor = findNextDocgenTagStart(lastUnprintedIdx);
+                if (cursor == -1) {
+                    break parseText;
+                } else {
+                    lastDocgenTagStart = cursor;
+                }
+
+                out.write(text, lastUnprintedIdx, cursor - lastUnprintedIdx);
+                lastUnprintedIdx = cursor;
+
+                cursor += DOCGEN_TAG_START.length();
+                skipRequiredToken(".");
+                String subvarName = fetchRequiredVariableName();
+
+                if (Transform.VAR_CUSTOM_VARIABLES.equals(subvarName)) {
+                    skipRequiredToken(".");
+                    String customVarName = fetchRequiredVariableName();
+                    skipRequiredToken(DOCGEN_TAG_END);
+                    lastUnprintedIdx = cursor;
+
+                    insertCustomVariable(customVarName);
+                } else if (INSERT_FILE.equals(subvarName)) {
+                    skipWS();
+                    String pathArg = fetchRequiredString();
+                    skipRequiredToken(DOCGEN_TAG_END);
+                    lastUnprintedIdx = cursor;
+
+                    insertFile(pathArg);
+                } else {
+                    throw new TemplateException(
+                            "Unsupported docgen subvariable " + StringUtil.jQuote(subvarName) + ".", env);
+                }
+
+            }
+            out.write(text, lastUnprintedIdx, text.length() - lastUnprintedIdx);
+        }
+
+        private void insertCustomVariable(String customVarName) throws TemplateException, IOException {
+            TemplateHashModel customVariables =
+                    Objects.requireNonNull(
+                            (TemplateHashModel) env.getVariable(Transform.VAR_CUSTOM_VARIABLES));
+            TemplateModel customVarValue = customVariables.get(customVarName);
+            if (customVarValue == null) {
+                throw newErrorInDocgenTag(
+                        "Docgen custom variable " + StringUtil.jQuote(customVarName)
+                                + " wasn't defined or is null.");
+            }
+
+            printValue(customVarName, customVarValue);
+        }
+
+        /** Horrible hack to mimic ${var}; the public FreeMarker API should have something like this! */
+        private void printValue(String varName, TemplateModel varValue) throws TemplateException,
+                IOException {
+            Object formattedValue;
+            if (varValue instanceof TemplateNumberModel) {
+                try {
+                    formattedValue = env.getTemplateNumberFormat().format((TemplateNumberModel) varValue);
+                } catch (TemplateValueFormatException e) {
+                    throw newFormattingFailedException(varName, e);
+                }
+            } else if (varValue instanceof TemplateDateModel) {
+                TemplateDateModel tdm = (TemplateDateModel) varValue;
+                try {
+                    formattedValue = env.getTemplateDateFormat(tdm.getDateType(), tdm.getAsDate().getClass())
+                            .format(tdm);
+                } catch (TemplateValueFormatException e) {
+                    throw newFormattingFailedException(varName, e);
+                }
+            } else if (varValue instanceof TemplateScalarModel) {
+                formattedValue = ((TemplateScalarModel) varValue).getAsString();
+            } else if (varValue instanceof TemplateBooleanModel) {
+                String[] booleanStrValues = env.getBooleanFormat().split(",");
+                formattedValue = ((TemplateBooleanModel) varValue).getAsBoolean()
+                        ? booleanStrValues[0] : booleanStrValues[1];
+            } else {
+                throw new TemplateException(
+                        "Docgen custom variable " + StringUtil.jQuote(varName)
+                                + " has an unsupported type: "
+                                + ClassUtil.getFTLTypeDescription(varValue),
+                        env);
+            }
+            if (formattedValue instanceof String) {
+                HTMLOutputFormat.INSTANCE.output((String) formattedValue, out);
+            } else {
+                HTMLOutputFormat.INSTANCE.output((TemplateHTMLOutputModel) formattedValue, out);
+            }
+        }
+
+        private void insertFile(String pathArg) throws TemplateException, IOException {
+            int slashIndex = pathArg.indexOf("/");
+            String symbolicNameStep = slashIndex != -1 ? pathArg.substring(0, slashIndex) : pathArg;
+            if (!symbolicNameStep.startsWith("@") || symbolicNameStep.length() < 2) {
+                throw newErrorInDocgenTag("Path argument must start with @<symbolicName>/, "
+                        + " where <symbolicName> is in " + transform.getInsertableFiles().keySet() + ".");
+            }
+            String symbolicName = symbolicNameStep.substring(1);
+            Path symbolicNamePath = transform.getInsertableFiles().get(symbolicName)
+                    .toAbsolutePath().normalize();
+            Path resolvedFilePath = slashIndex != -1
+                    ? symbolicNamePath.resolve(pathArg.substring(slashIndex + 1))
+                    : symbolicNamePath;
+            resolvedFilePath = resolvedFilePath.normalize();
+            if (!resolvedFilePath.startsWith(symbolicNamePath)) {
+                throw newErrorInDocgenTag("Resolved path ("
+                        + resolvedFilePath + ") is not inside the base path ("
+                        + symbolicNamePath + ").");
+            }
+            if (!Files.isRegularFile(resolvedFilePath)) {
+                throw newErrorInDocgenTag("Not an existing file: " + resolvedFilePath);
+            }
+            try (InputStream in = Files.newInputStream(resolvedFilePath)) {
+                String fileContent = IOUtils.toString(in, StandardCharsets.UTF_8);
+                String fileExt = FilenameUtils.getExtension(resolvedFilePath.getFileName().toString());
+                if (fileExt != null && fileExt.toLowerCase().startsWith("ftl")) {
+                    fileContent = removeFTLCopyrightComment(fileContent);
+                }
+                HTMLOutputFormat.INSTANCE.output(fileContent, out);
+            }
+        }
+
+        private TemplateException newFormattingFailedException(String customVarName, TemplateValueFormatException e) {
+            return new TemplateException(
+                    "Formatting failed for Docgen custom variable "
+                            + StringUtil.jQuote(customVarName),
+                    e, env);
+        }
+
+        private int findNextDocgenTagStart(int lastUnprintedIdx) {
+            int startIdx = text.indexOf(DOCGEN_TAG_START, lastUnprintedIdx);
+            if (startIdx == -1) {
+                return -1;
+            }
+            int afterTagStartIdx = startIdx + DOCGEN_TAG_START.length();
+            if (afterTagStartIdx < text.length()
+                    && !Character.isJavaIdentifierPart(text.charAt(afterTagStartIdx))) {
+                return startIdx;
+            }
+            return -1;
+        }
+
+        private void skipWS() {
+            while (cursor < text.length()) {
+                if (Character.isWhitespace(text.charAt(cursor))) {
+                    cursor++;
+                } else {
+                    break;
+                }
+            }
+        }
+
+        private void skipRequiredToken(String token) throws TemplateException {
+            if (!skipOptionalToken(token)) {
+                throw newUnexpectedTokenException(StringUtil.jQuote(token), env);
+            }
+        }
+
+        private boolean skipOptionalToken(String token) throws TemplateException {
+            skipWS();
+            for (int i = 0; i < token.length(); i++) {
+                char expectedChar = token.charAt(i);
+                int lookAheadCursor = cursor + i;
+                if (charAt(lookAheadCursor) != expectedChar) {
+                    return false;
+                }
+            }
+            cursor += token.length();
+            skipWS();
+            return true;
+        }
+
+        private String fetchRequiredVariableName() throws TemplateException {
+            String varName = fetchOptionalVariableName();
+            if (varName == null) {
+                throw newUnexpectedTokenException("variable name", env);
+            }
+            return varName;
+        }
+
+        private String fetchOptionalVariableName() {
+            if (!Character.isJavaIdentifierStart(charAt(cursor))) {
+                return null;
+            }
+            int varNameStart = cursor;
+            cursor++;
+            while (Character.isJavaIdentifierPart(charAt(cursor))) {
+                cursor++;
+            }
+            return text.substring(varNameStart, cursor);
+        }
+
+        private String fetchRequiredString() throws TemplateException {
+            String result = fetchOptionalString();
+            if (result == null) {
+                throw newUnexpectedTokenException("string literal", env);
+            }
+            return result;
+        }
+
+        private String fetchOptionalString() throws TemplateException {
+            char quoteChar = charAt(cursor);
+            if (quoteChar != '"' && quoteChar != '\'') {
+                return null;
+            }
+            cursor++;
+            int stringStartIdx = cursor;
+            while (cursor < text.length() && charAt(cursor) != quoteChar) {
+                if (charAt(cursor) == '\\') {
+                    throw new DocgenSubstitutionTemplateException(
+                            "Backslash is currently not supported in string literal in Docgen tags.", env);
+                }
+                cursor++;
+            }
+            if (charAt(cursor) != quoteChar) {
+                throw new DocgenSubstitutionTemplateException("Unclosed string literal in a Docgen tag.", env);
+            }
+            String result = text.substring(stringStartIdx, cursor);
+            cursor++;
+            return result;
+        }
+
+        private char charAt(int index) {
+            return index < text.length() ? text.charAt(index) : 0;
+        }
+
+        private TemplateException newUnexpectedTokenException(String expectedTokenDesc, Environment env) {
+            return new DocgenSubstitutionTemplateException(
+                    "Expected " + expectedTokenDesc + " after this: " + text.substring(lastDocgenTagStart, cursor),
+                    env);
+        }
+
+        private TemplateException newErrorInDocgenTag(String errorDetail) {
+            return new DocgenSubstitutionTemplateException(
+                    "\nError in docgen tag: " + text.substring(lastDocgenTagStart, cursor) + "\n" + errorDetail,
+                    env);
+
+        }
+    }
+
+    public static String removeFTLCopyrightComment(String ftl) {
+        int copyrightPartIdx = ftl.indexOf("Licensed to the Apache Software Foundation");
+        if (copyrightPartIdx == -1) {
+            return ftl;
+        }
+
+        final int commentFirstIdx;
+        final boolean squareBracketTagSyntax;
+        {
+            String ftlBeforeCopyright = ftl.substring(0, copyrightPartIdx);
+            int abCommentStart = ftlBeforeCopyright.lastIndexOf("<#--");
+            int sbCommentStart = ftlBeforeCopyright.lastIndexOf("[#--");
+            squareBracketTagSyntax = sbCommentStart > abCommentStart;
+            commentFirstIdx = squareBracketTagSyntax ? sbCommentStart : abCommentStart;
+            if (commentFirstIdx == -1) {
+                throw new AssertionError("Can't find copyright comment start");
+            }
+        }
+
+        final int commentLastIdx;
+        {
+            int commentEndStart = ftl.indexOf(squareBracketTagSyntax ? "--]" : "-->", copyrightPartIdx);
+            if (commentEndStart == -1) {
+                throw new AssertionError("Can't find copyright comment end");
+            }
+            commentLastIdx = commentEndStart + 2;
+        }
+
+        final int afterCommentNLChars;
+        if (commentLastIdx + 1 < ftl.length()) {
+            char afterCommentChar = ftl.charAt(commentLastIdx + 1);
+            if (afterCommentChar == '\n' || afterCommentChar == '\r') {
+                if (afterCommentChar == '\r' && commentLastIdx + 2 < ftl.length()
+                        && ftl.charAt(commentLastIdx + 2) == '\n') {
+                    afterCommentNLChars = 2;
+                } else {
+                    afterCommentNLChars = 1;
+                }
+            } else {
+                afterCommentNLChars = 0;
+            }
+        } else {
+            afterCommentNLChars = 0;
+        }
+
+        return ftl.substring(0, commentFirstIdx) + ftl.substring(commentLastIdx + afterCommentNLChars + 1);
+    }
+
+}
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 f2a5304..d9d3a25 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
@@ -33,7 +33,6 @@ import java.nio.charset.Charset;
 import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 import java.nio.file.Path;
-import java.nio.file.Paths;
 import java.text.Collator;
 import java.util.ArrayList;
 import java.util.Collections;
@@ -136,7 +135,6 @@ public final class Transform {
     static final String SETTING_TIME_ZONE = "timeZone";
     static final String SETTING_LOCALE = "locale";
     static final String SETTING_CONTENT_DIRECTORY = "contentDirectory";
-    static final String SETTING_CUSTOM_VARIABLE_FILE_DIRECTORY = "customVariableFileDirectory";
     static final String SETTING_LOWEST_PAGE_TOC_ELEMENT_RANK
             = "lowestPageTOCElementRank";
     static final String SETTING_LOWEST_FILE_ELEMENT_RANK
@@ -146,7 +144,7 @@ public final class Transform {
             = "maxMainTOFDisplayDepth";
     static final String SETTING_NUMBERED_SECTIONS = "numberedSections";
     static final String SETTING_CUSTOM_VARIABLES = "customVariables";
-    static final String SETTING_CUSTOM_VARIABLES_FROM_FILES = "customVariablesFromFiles";
+    static final String SETTING_INSERTABLE_FILES = "insertableFiles";
 
     static final String SETTING_VALIDATION_PROGRAMLISTINGS_REQ_ROLE
             = "programlistingsRequireRole";
@@ -223,7 +221,7 @@ public final class Transform {
             = SETTING_MAX_TOF_DISPLAY_DEPTH;
     private static final String VAR_NUMBERED_SECTIONS
             = SETTING_NUMBERED_SECTIONS;
-    private static final String VAR_CUSTOM_VARIABLES = SETTING_CUSTOM_VARIABLES;
+    static final String VAR_CUSTOM_VARIABLES = SETTING_CUSTOM_VARIABLES;
     private static final String VAR_INDEX_ENTRIES
             = "indexEntries";
     private static final String VAR_PAGE_TYPE = "pageType";
@@ -407,12 +405,15 @@ public final class Transform {
 
     private boolean printProgress;
 
-    private LinkedHashMap<String, String> internalBookmarks = new LinkedHashMap<String, String>();
+    private LinkedHashMap<String, String> internalBookmarks = new LinkedHashMap<>();
     private LinkedHashMap<String, String> externalBookmarks = new LinkedHashMap<>();
     private Map<String, Map<String, String>> footerSiteMap;
 
-    private Map<String, Object> customVariableOverrides = new HashMap<>();
     private Map<String, Object> customVariablesFromSettingsFile = new HashMap<>();
+    private Map<String, Object> customVariableOverrides = new HashMap<>();
+
+    private Map<String, String> insertableFilesFromSettingsFile = new HashMap<>();
+    private Map<String, String> insertableFilesOverrides = new HashMap<>();
 
     private LinkedHashMap<String, String> tabs = new LinkedHashMap<>();
 
@@ -447,6 +448,7 @@ public final class Transform {
     private Map<String, Element> elementsById;
     private List<TOCNode> tocNodes;
     private List<String> indexEntries;
+    private Map<String, Path> insertableFiles;
     private Configuration fmConfig;
 
     // -------------------------------------------------------------------------
@@ -607,12 +609,16 @@ public final class Transform {
                 } else if (settingName.equals(SETTING_CUSTOM_VARIABLES)) {
                     customVariablesFromSettingsFile.putAll(
                             castSettingToMapWithStringKeys(cfgFile, settingName, settingValue));
-                } else if (settingName.equals(SETTING_CUSTOM_VARIABLES_FROM_FILES)) {
+                } else if (settingName.equals(SETTING_INSERTABLE_FILES)) {
                     Map<String, Object> m = castSettingToMapWithStringKeys(
                             cfgFile, settingName, settingValue);
                     for (Entry<String, Object> ent : m.entrySet()) {
                         String value = castSettingValueMapValueToString(cfgFile, settingName, ent.getValue());
-                        customVariablesFromSettingsFile.put(ent.getKey(), new FileContentPlaceholder(value));
+                        if (insertableFilesFromSettingsFile.put(ent.getKey(), value) != null) {
+                            throw new DocgenException(
+                                    "Duplicate key " + StringUtil.jQuote(ent.getKey()) + " in "
+                                            + SETTING_INSERTABLE_FILES + ".");
+                        }
                     }
                 } else if (settingName.equals(SETTING_TABS)) {
                     Map<String, Object> m = castSettingToMapWithStringKeys(
@@ -766,15 +772,6 @@ public final class Transform {
                                 "It's not an existing directory: "
                                 + contentDir.getAbsolutePath());
                     }
-                } else if (settingName.equals(SETTING_CUSTOM_VARIABLE_FILE_DIRECTORY)) {
-                    String s = castSettingToString(
-                            cfgFile, settingName, settingValue);
-                    customVariableFileDir = new File(srcDir, s);
-                    if (!customVariableFileDir.isDirectory()) {
-                        throw newCfgFileException(cfgFile, settingName,
-                                "It's not an existing directory: "
-                                        + customVariableFileDir.getAbsolutePath());
-                    }
                 } else if (settingName.equals(SETTING_LOWEST_FILE_ELEMENT_RANK)
                         || settingName.equals(
                                 SETTING_LOWEST_PAGE_TOC_ELEMENT_RANK)) {
@@ -1003,6 +1000,8 @@ public final class Transform {
             }
         }
 
+        insertableFiles = computeInsertableFiles();
+
         // - Setup common data-model variables:
         try {
             // Settings:
@@ -1061,6 +1060,10 @@ public final class Transform {
             fmConfig.setSharedVariable(
                     VAR_CUSTOM_VARIABLES, computeCustomVariables());
 
+            fmConfig.setSharedVariable(
+                    "printTextWithDocgenSubstitutions",
+                    new PrintTextWithDocgenSubstitutionsDirective(this));
+
             // Calculated data:
             {
                 Date generationTime;
@@ -1162,6 +1165,8 @@ public final class Transform {
                     }
                 } catch (freemarker.core.StopException e) {
                     throw new DocgenException(e.getMessage());
+                } catch (DocgenSubstitutionTemplateException e) {
+                    throw new DocgenException("Docgen substitution in document text failed; see cause exception", e);
                 } catch (TemplateException e) {
                     throw new BugException(e);
                 }
@@ -1248,40 +1253,69 @@ public final class Transform {
         customVariables.putAll(customVariableOverrides);
 
         for (Entry<String, Object> entry : customVariables.entrySet()) {
-            Object value = entry.getValue();
-            if (value instanceof FileContentPlaceholder) {
-                Path varContentPath = Paths.get(((FileContentPlaceholder) value).path);
-                Path absVarContentPath;
-                if (varContentPath.isAbsolute()) {
-                    absVarContentPath = varContentPath;
-                } else {
-                    if (customVariableFileDir == null) {
-                        throw new DocgenException("Can't resolve custom variable file "
-                                + StringUtil.jQuote(varContentPath.toString()) + ", as the "
-                                + SETTING_CUSTOM_VARIABLE_FILE_DIRECTORY + " setting wasn't set.");
-                    }
-                    absVarContentPath = customVariableFileDir.toPath().resolve(varContentPath);
-                }
+            if (entry.getValue() == null) {
+                throw new DocgenException("The custom variable " + StringUtil.jQuote(entry.getKey())
+                        + " was set to null, which is not allowed. Probably you are supposed to override its value.");
+            }
+        }
 
-                String varValue;
-                try {
-                    varValue = new String(Files.readAllBytes(absVarContentPath), StandardCharsets.UTF_8);
-                } catch (IOException e) {
-                    throw new DocgenException("Can't read the file that stores the value of custom variable "
-                        + StringUtil.jQuote(entry.getKey()) + ": " + absVarContentPath);
-                }
-                entry.setValue(varValue);
+        return customVariables;
+    }
+
+    private Map<String, Path> computeInsertableFiles() throws DocgenException {
+        for (String varName : insertableFilesOverrides.keySet()) {
+            if (!insertableFilesFromSettingsFile.containsKey(varName)) {
+                throw new DocgenException("Attempt to set insertable path with symbolic name "
+                        + StringUtil.jQuote(varName)
+                        + ", when same was not set in the settings file (" + FILE_SETTINGS + ").");
             }
         }
 
-        for (Entry<String, Object> entry : customVariables.entrySet()) {
+        Map<String, String> unresolvedInsertableFiles = new HashMap<>();
+        unresolvedInsertableFiles.putAll(insertableFilesFromSettingsFile);
+        unresolvedInsertableFiles.putAll(insertableFilesOverrides);
+
+        for (Entry<String, String> entry : unresolvedInsertableFiles.entrySet()) {
             if (entry.getValue() == null) {
-                throw new DocgenException("The custom variable " + StringUtil.jQuote(entry.getKey())
-                        + " was set to null, which is not allowed. Probably you are supposed to override its value.");
+                throw new DocgenException("The insertable path with symbolic name "
+                        + StringUtil.jQuote(entry.getKey()) + " was set to path null, which is not allowed. "
+                        + "Probably you are supposed to override its path.");
             }
         }
 
-        return customVariables;
+        Map<String, Path> insertableFiles = new HashMap<>();
+        for (Entry<String, String> entry : unresolvedInsertableFiles.entrySet()) {
+            String symbolicName = entry.getKey();
+            String unresolvedPath = entry.getValue();
+
+            Path path;
+            if (unresolvedPath.endsWith("/**") || unresolvedPath.endsWith("\\**")) {
+                path = srcDir.toPath().resolve(unresolvedPath.substring(0, unresolvedPath.length() - 3));
+                if (!Files.isDirectory(path)) {
+                    throw new DocgenException(
+                            "Insertable file with symbolic name " + StringUtil.jQuote(symbolicName)
+                            + " points to a directory that doesn't exist: " + StringUtil.jQuote(path));
+                }
+            } else {
+                path = srcDir.toPath().resolve(unresolvedPath);
+                if (!Files.isRegularFile(path)) {
+                    if (Files.isDirectory(path)) {
+                        throw new DocgenException(
+                                "Insertable file with symbolic name " + StringUtil.jQuote(symbolicName)
+                                + " points to a directory, not a file: " + StringUtil.jQuote(path) + "."
+                                + " If you want to point to a directory, end the path with \"/**\".");
+                    } else {
+                        throw new DocgenException(
+                                "Insertable file with symbolic name " + StringUtil.jQuote(symbolicName)
+                                        + " points to a file that doesn't exist: " + StringUtil.jQuote(path));
+                    }
+                }
+            }
+
+            insertableFiles.put(symbolicName, path);
+        }
+
+        return insertableFiles;
     }
 
     private void resolveLogoHref(Logo logo) throws DocgenException {
@@ -1474,7 +1508,7 @@ public final class Transform {
 
     private String castSettingValueMapValueToString(File cfgFile,
             String settingName, Object mapEntryValue) throws DocgenException {
-        if (!(mapEntryValue instanceof String)) {
+        if (mapEntryValue != null && !(mapEntryValue instanceof String)) {
             throw newCfgFileException(cfgFile, settingName,
                     "The values in the key-value pairs of this map must be "
                     + "strings, but some of them is a "
@@ -2813,6 +2847,12 @@ public final class Transform {
 
     // -------------------------------------------------------------------------
 
+    Map<String, Path> getInsertableFiles() {
+        return insertableFiles;
+    }
+
+    // -------------------------------------------------------------------------
+
     public File getDestinationDirectory() {
         return destDir;
     }
@@ -2917,10 +2957,8 @@ public final class Transform {
         this.customVariableOverrides.putAll(customVariables);
     }
 
-    public void addCustomVariableOverridesFromFiles(Map<String, String> customVariablesFromFiles) {
-        for (Entry<String, String> entry : customVariablesFromFiles.entrySet()) {
-            customVariableOverrides.put(entry.getKey(), new FileContentPlaceholder(entry.getValue()));
-        }
+    public void addInsertableFileOverrides(Map<String, String> insertableFilesOverrides) {
+        this.insertableFilesOverrides.putAll(insertableFilesOverrides);
     }
 
     // -------------------------------------------------------------------------
@@ -3047,12 +3085,4 @@ public final class Transform {
         }
     }
 
-    private class FileContentPlaceholder {
-        private final String path;
-
-        public FileContentPlaceholder(String path) {
-            this.path = path;
-        }
-    }
-
 }
diff --git a/freemarker-docgen-core/src/main/resources/org/freemarker/docgen/core/templates/footer.ftlh b/freemarker-docgen-core/src/main/resources/org/freemarker/docgen/core/templates/footer.ftlh
index 8c51f7b..92ba2f0 100644
--- a/freemarker-docgen-core/src/main/resources/org/freemarker/docgen/core/templates/footer.ftlh
+++ b/freemarker-docgen-core/src/main/resources/org/freemarker/docgen/core/templates/footer.ftlh
@@ -62,7 +62,7 @@
     </time><#t>
     <#local book = .node?root.*>
     <#if book.info.productname?hasContent>
-      , for <@u.printWithResolvedPlaceholders book.info.productname /><#t>
+      , for <@printTextWithDocgenSubstitutions text=book.info.productname /><#t>
     </#if>
   </p>
 </#macro>
diff --git a/freemarker-docgen-core/src/main/resources/org/freemarker/docgen/core/templates/node-handlers.ftlh b/freemarker-docgen-core/src/main/resources/org/freemarker/docgen/core/templates/node-handlers.ftlh
index 30a5252..601af62 100644
--- a/freemarker-docgen-core/src/main/resources/org/freemarker/docgen/core/templates/node-handlers.ftlh
+++ b/freemarker-docgen-core/src/main/resources/org/freemarker/docgen/core/templates/node-handlers.ftlh
@@ -32,7 +32,7 @@
 <#assign footnotes = []>
 
 <#macro @text>
-  <@u.printWithResolvedPlaceholders .node /><#t>
+  <@printTextWithDocgenSubstitutions text=.node /><#t>
 </#macro>
 
 <#macro @element>
diff --git a/freemarker-docgen-core/src/main/resources/org/freemarker/docgen/core/templates/util.ftl b/freemarker-docgen-core/src/main/resources/org/freemarker/docgen/core/templates/util.ftl
index 8f28676..d3ceacf 100644
--- a/freemarker-docgen-core/src/main/resources/org/freemarker/docgen/core/templates/util.ftl
+++ b/freemarker-docgen-core/src/main/resources/org/freemarker/docgen/core/templates/util.ftl
@@ -118,22 +118,3 @@
 <#macro invisible1x1Img>
   <img src="docgen-resources/img/none.gif" width="1" height="1" alt="" hspace="0" vspace="0" border="0"/><#t>
 </#macro>
-
-<#macro printWithResolvedPlaceholders text>
-  <#if text?contains(r"[docgen.customVariables.")>
-    <#local s = text>
-    <#list text?matches(r"\[docgen\.customVariables\.(.+?)\]") as match>
-      <#local replaced = match?groups[0]>
-      <#local customVarName = match?groups[1]>
-      <#attempt>
-        <#local customVarValue = customVariables[customVarName]>
-      <#recover>
-        <#stop "Failed to resolve custom variable \"${customVarName}\" in text \"${replaced}\": ${.error}">
-      </#attempt>
-      <#local s = s?replace(replaced, customVarValue)>
-    </#list>
-    ${s}<#t>
-  <#else>
-    ${text}<#t>
-  </#if>
-</#macro>
diff --git a/freemarker-docgen-maven/src/main/java/org/freemarker/docgen/maven/TransformMojo.java b/freemarker-docgen-maven/src/main/java/org/freemarker/docgen/maven/TransformMojo.java
index ae4385b..f58d1c5 100644
--- a/freemarker-docgen-maven/src/main/java/org/freemarker/docgen/maven/TransformMojo.java
+++ b/freemarker-docgen-maven/src/main/java/org/freemarker/docgen/maven/TransformMojo.java
@@ -63,15 +63,9 @@ public class TransformMojo extends AbstractMojo {
     private Map<String, Object> customVariables;
 
     @Parameter()
-    private Map<String, String> customVariablesFromFiles;
-
-    /**
-     * The maven project.
-     *
-     * @parameter expression="${project}"
-     * @readonly
-     */
-    @Parameter(defaultValue = "${${project.base}}", readonly=true)
+    private Map<String, String> insertableFiles;
+
+    @Parameter(defaultValue = "${project.base}", readonly=true)
     private String projectBaseDirectory;
 
     @Override
@@ -100,8 +94,8 @@ public class TransformMojo extends AbstractMojo {
         if (customVariables != null) {
             transform.addCustomVariableOverrides(customVariables);
         }
-        if (customVariablesFromFiles != null) {
-            transform.addCustomVariableOverridesFromFiles(customVariablesFromFiles);
+        if (insertableFiles != null) {
+            transform.addInsertableFileOverrides(insertableFiles);
         }
         transform.setPrintProgress(printProgress); // TODO Use Maven logging for this