You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maven.apache.org by sl...@apache.org on 2026/08/08 21:14:56 UTC
(maven-doxia-converter) 01/03: Keep Velocity constructs intact when converting a *.vm source
This is an automated email from the ASF dual-hosted git repository.
slachiewicz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/maven-doxia-converter.git
commit 1165f377ad74d7e898dde69aa6b693227d5112c9
Author: Sylwester Lachiewicz <sl...@apache.org>
AuthorDate: Wed Aug 5 22:04:56 2026 +0200
Keep Velocity constructs intact when converting a *.vm source
A *.vm file is only valid Doxia markup after Velocity has run, but the
converter hands the unprocessed source to the parser, which mangles what
it does not understand:
- ${project.name} used as a section title came out as
'# $<a id="project.name"></a>project.name', because APT reads {...}
in a title as an anchor definition
- '#set( $prefix = '.' )' came out as escaped body text
VelocityMasker replaces references, directive lines and block comments
with opaque placeholders before parsing and substitutes the originals
back into the converted document afterwards. Only applied when the input
file ends in .vm, which the converter already detects.
A reference returns to exactly where it was. A directive occupying a
whole line is a block of its own to the parser, so it survives but may
land in the wrong place; those are logged as a warning to be checked by
hand rather than silently mangled.
---
.../org/apache/maven/doxia/DefaultConverter.java | 49 +++++++--
.../org/apache/maven/doxia/VelocityMasker.java | 118 +++++++++++++++++++++
.../org/apache/maven/doxia/VelocityMaskerTest.java | 93 ++++++++++++++++
3 files changed, 252 insertions(+), 8 deletions(-)
diff --git a/src/main/java/org/apache/maven/doxia/DefaultConverter.java b/src/main/java/org/apache/maven/doxia/DefaultConverter.java
index 6986815..8ae1efd 100644
--- a/src/main/java/org/apache/maven/doxia/DefaultConverter.java
+++ b/src/main/java/org/apache/maven/doxia/DefaultConverter.java
@@ -29,7 +29,9 @@ import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.Reader;
+import java.io.StringReader;
import java.io.Writer;
+import java.nio.charset.Charset;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
@@ -45,6 +47,7 @@ import java.util.stream.Collectors;
import com.ibm.icu.text.CharsetDetector;
import com.ibm.icu.text.CharsetMatch;
+import org.apache.commons.io.IOUtils;
import org.apache.commons.io.input.XmlStreamReader;
import org.apache.commons.lang3.StringUtils;
import org.apache.maven.doxia.macro.MacroExecutionException;
@@ -600,6 +603,20 @@ public class DefaultConverter implements Converter {
throw new ConverterException("IOException: " + e.getMessage(), e);
}
+ // a *.vm source is only valid markup after Velocity has run, so hide the Velocity constructs
+ // from the parser and put them back into the converted document afterwards
+ final VelocityMasker velocityMasker;
+ if (isVelocityTemplate) {
+ velocityMasker = new VelocityMasker();
+ try (Reader r = reader) {
+ reader = new StringReader(velocityMasker.mask(IOUtils.toString(r)));
+ } catch (IOException e) {
+ throw new ConverterException("IOException: " + e.getMessage(), e);
+ }
+ } else {
+ velocityMasker = null;
+ }
+
SinkFactory sinkFactory;
try {
sinkFactory = output.getFormat().getSinkFactory(plexus);
@@ -607,16 +624,15 @@ public class DefaultConverter implements Converter {
throw new ConverterException("ComponentLookupException: " + e.getMessage(), e);
}
+ final String outputEncoding;
+ if (StringUtils.isEmpty(output.getEncoding()) || output.getEncoding().equals(OutputFileWrapper.AUTO_ENCODING)) {
+ outputEncoding = inputEncoding;
+ } else {
+ outputEncoding = output.getEncoding();
+ }
+
Sink sink;
try {
- String outputEncoding;
- if (StringUtils.isEmpty(output.getEncoding())
- || output.getEncoding().equals(OutputFileWrapper.AUTO_ENCODING)) {
- outputEncoding = inputEncoding;
- } else {
- outputEncoding = output.getEncoding();
- }
-
OutputStream out = new FileOutputStream(outputFile);
sink = sinkFactory.createSink(out, outputEncoding);
} catch (IOException e) {
@@ -630,6 +646,23 @@ public class DefaultConverter implements Converter {
throw new ConverterException(
"Error converting file \"" + inputFile.getAbsolutePath() + "\": " + e.getMessage(), e);
}
+ if (velocityMasker != null) {
+ Charset charset = Charset.forName(outputEncoding);
+ try {
+ String converted = new String(Files.readAllBytes(outputFile.toPath()), charset);
+ Files.write(
+ outputFile.toPath(), velocityMasker.unmask(converted).getBytes(charset));
+ } catch (IOException e) {
+ throw new ConverterException("IOException: " + e.getMessage(), e);
+ }
+ for (String directive : velocityMasker.getMaskedDirectives()) {
+ LOGGER.warn(
+ "Velocity directive \"{}\" was kept but the parser treated it as ordinary content, "
+ + "so check its placement in \"{}\"",
+ directive.trim(),
+ outputFile.getName());
+ }
+ }
if (formatOutput && output.getFormat().isXml()) {
try (Reader r = ReaderFactory.newXmlReader(outputFile);
Writer w = WriterFactory.newXmlWriter(outputFile)) {
diff --git a/src/main/java/org/apache/maven/doxia/VelocityMasker.java b/src/main/java/org/apache/maven/doxia/VelocityMasker.java
new file mode 100644
index 0000000..3d9d915
--- /dev/null
+++ b/src/main/java/org/apache/maven/doxia/VelocityMasker.java
@@ -0,0 +1,118 @@
+/*
+ * 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.apache.maven.doxia;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Hides Velocity constructs from a Doxia parser and restores them afterwards.
+ *
+ * <p>A {@code *.vm} source file is only valid Doxia markup <em>after</em> Velocity has processed it.
+ * When such a file is converted, the parser sees the unprocessed source and mangles anything it does
+ * not understand: {@code #set( $prefix = '.' )} ends up as escaped body text and a reference used as
+ * a section title, such as {@code ${project.name}}, is read as an anchor definition because the APT
+ * syntax gives {@code {...}} a meaning of its own.</p>
+ *
+ * <p>Replacing every Velocity construct with an opaque placeholder before parsing, and substituting
+ * the original text back into the generated document afterwards, keeps those constructs intact and
+ * in place.</p>
+ */
+class VelocityMasker {
+
+ /**
+ * Wraps a placeholder. Deliberately built from ASCII letters and digits only: those are never
+ * escaped by a sink and always survive the output encoding, which characters outside US-ASCII
+ * (such as the Unicode private use area) do not.
+ */
+ private static final String PLACEHOLDER_START = "VELOCITYMASK";
+
+ private static final String PLACEHOLDER_END = "ENDMASK";
+
+ /** A block comment {@code #* ... *#}, which may span several lines. */
+ private static final Pattern BLOCK_COMMENT = Pattern.compile("#\\*.*?\\*#", Pattern.DOTALL);
+
+ /**
+ * A whole line holding nothing but a directive or a line comment, such as {@code #if( $a )},
+ * {@code #end} or {@code ## remark}. No APT, XDoc or FML construct starts a line with {@code #},
+ * so the whole line can be taken verbatim.
+ */
+ private static final Pattern DIRECTIVE_LINE = Pattern.compile("^[ \\t]*#[^\\n]*", Pattern.MULTILINE);
+
+ /** A formal reference, {@code ${...}} or the quiet form {@code $!{...}}. */
+ private static final Pattern REFERENCE = Pattern.compile("\\$!?\\{[^{}\\n]*\\}");
+
+ private final List<String> maskedValues = new ArrayList<>();
+
+ private final List<String> maskedDirectives = new ArrayList<>();
+
+ /**
+ * @param content the raw source of a Velocity template
+ * @return the same content with every Velocity construct replaced by a placeholder
+ */
+ String mask(String content) {
+ String masked = mask(content, BLOCK_COMMENT);
+ int directivesStart = maskedValues.size();
+ masked = mask(masked, DIRECTIVE_LINE);
+ maskedDirectives.addAll(maskedValues.subList(directivesStart, maskedValues.size()));
+ return mask(masked, REFERENCE);
+ }
+
+ /**
+ * A reference is masked in place and comes back where it was, but a directive occupying a whole
+ * line is a block of its own to the parser, so the converted document may well put it somewhere
+ * the template no longer works. Those need to be checked by hand.
+ *
+ * @return the directives and line comments which took up a whole line, in order of appearance
+ */
+ List<String> getMaskedDirectives() {
+ return maskedDirectives;
+ }
+
+ private String mask(String content, Pattern pattern) {
+ Matcher matcher = pattern.matcher(content);
+ StringBuffer buffer = new StringBuffer(content.length());
+ while (matcher.find()) {
+ maskedValues.add(matcher.group());
+ matcher.appendReplacement(buffer, Matcher.quoteReplacement(placeholder(maskedValues.size() - 1)));
+ }
+ matcher.appendTail(buffer);
+ return buffer.toString();
+ }
+
+ /**
+ * @param content a converted document still holding the placeholders produced by {@link #mask(String)}
+ * @return the same content with the original Velocity constructs substituted back in
+ */
+ String unmask(String content) {
+ String unmasked = content;
+ // replace in reverse order so that a placeholder is never found inside restored text
+ for (int i = maskedValues.size() - 1; i >= 0; i--) {
+ unmasked = unmasked.replace(placeholder(i), maskedValues.get(i));
+ }
+ return unmasked;
+ }
+
+ private static String placeholder(int index) {
+ // the trailing marker keeps a lower index from being a prefix of a higher one
+ return PLACEHOLDER_START + index + PLACEHOLDER_END;
+ }
+}
diff --git a/src/test/java/org/apache/maven/doxia/VelocityMaskerTest.java b/src/test/java/org/apache/maven/doxia/VelocityMaskerTest.java
new file mode 100644
index 0000000..12a0657
--- /dev/null
+++ b/src/test/java/org/apache/maven/doxia/VelocityMaskerTest.java
@@ -0,0 +1,93 @@
+/*
+ * 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.apache.maven.doxia;
+
+import java.util.Arrays;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class VelocityMaskerTest {
+
+ @Test
+ void referencesAreHiddenAndRestored() {
+ VelocityMasker masker = new VelocityMasker();
+ String source = "Version ${project.version} of ${project.artifactId}.";
+
+ String masked = masker.mask(source);
+
+ assertFalse(masked.contains("${"), "references still visible to the parser: " + masked);
+ assertEquals(source, masker.unmask(masked));
+ }
+
+ @Test
+ void quietReferencesAreHiddenAndRestored() {
+ VelocityMasker masker = new VelocityMasker();
+ String source = "See $!{project.url} for details.";
+
+ assertEquals(source, masker.unmask(masker.mask(source)));
+ }
+
+ @Test
+ void indexesAboveNineAreRestoredCorrectly() {
+ VelocityMasker masker = new VelocityMasker();
+ StringBuilder source = new StringBuilder();
+ for (int i = 0; i < 15; i++) {
+ source.append("value ${property").append(i).append("} ");
+ }
+
+ assertEquals(source.toString(), masker.unmask(masker.mask(source.toString())));
+ }
+
+ @Test
+ void directiveLinesAreHiddenRestoredAndReported() {
+ VelocityMasker masker = new VelocityMasker();
+ String source = "#set( $prefix = '.' )\n" + "Some text.\n" + "#parse( \"index.txt\" )\n";
+
+ String masked = masker.mask(source);
+
+ assertFalse(masked.contains("#set"), "directive still visible to the parser: " + masked);
+ assertTrue(masked.contains("Some text."));
+ assertEquals(source, masker.unmask(masked));
+ assertEquals(Arrays.asList("#set( $prefix = '.' )", "#parse( \"index.txt\" )"), masker.getMaskedDirectives());
+ }
+
+ @Test
+ void blockCommentsSpanningLinesAreHiddenAndRestored() {
+ VelocityMasker masker = new VelocityMasker();
+ String source = "before\n#*\n a comment\n*#\nafter\n";
+
+ String masked = masker.mask(source);
+
+ assertFalse(masked.contains("a comment"), "block comment still visible to the parser: " + masked);
+ assertEquals(source, masker.unmask(masked));
+ }
+
+ @Test
+ void plainTextIsLeftAlone() {
+ VelocityMasker masker = new VelocityMasker();
+ String source = "A price of $5, a shell variable $HOME and a C directive-looking #hashtag inline.";
+
+ assertEquals(source, masker.mask(source));
+ assertTrue(masker.getMaskedDirectives().isEmpty());
+ }
+}