From 4518d58bd45591164b3043a46ad3db4356a48c85 Mon Sep 17 00:00:00 2001 From: Konrad Windszus Date: Tue, 28 Nov 2023 18:05:10 +0100 Subject: [PATCH] [DOXIA-722] Optionally create anchors for index entries (used in TOC macro) --- .../apache/maven/doxia/index/IndexEntry.java | 124 +++++++++- .../maven/doxia/index/IndexingSink.java | 224 ++++++++++-------- .../maven/doxia/macro/MacroRequest.java | 4 +- .../maven/doxia/macro/toc/TocMacro.java | 8 +- .../maven/doxia/parser/AbstractParser.java | 16 ++ .../org/apache/maven/doxia/parser/Parser.java | 17 ++ .../impl/CreateAnchorsForIndexEntries.java | 44 ++++ .../CreateAnchorsForIndexEntriesFactory.java | 38 +++ .../sink/impl/UniqueAnchorNamesValidator.java | 52 ++++ .../UniqueAnchorNamesValidatorFactory.java | 38 +++ .../maven/doxia/index/IndexEntryTest.java | 35 ++- .../maven/doxia/macro/toc/TocMacroTest.java | 44 +++- .../src/test/resources/test.apt | 2 +- 13 files changed, 526 insertions(+), 120 deletions(-) create mode 100644 doxia-core/src/main/java/org/apache/maven/doxia/sink/impl/CreateAnchorsForIndexEntries.java create mode 100644 doxia-core/src/main/java/org/apache/maven/doxia/sink/impl/CreateAnchorsForIndexEntriesFactory.java create mode 100644 doxia-core/src/main/java/org/apache/maven/doxia/sink/impl/UniqueAnchorNamesValidator.java create mode 100644 doxia-core/src/main/java/org/apache/maven/doxia/sink/impl/UniqueAnchorNamesValidatorFactory.java diff --git a/doxia-core/src/main/java/org/apache/maven/doxia/index/IndexEntry.java b/doxia-core/src/main/java/org/apache/maven/doxia/index/IndexEntry.java index 973f6c16d..178e403cf 100644 --- a/doxia-core/src/main/java/org/apache/maven/doxia/index/IndexEntry.java +++ b/doxia-core/src/main/java/org/apache/maven/doxia/index/IndexEntry.java @@ -19,11 +19,18 @@ package org.apache.maven.doxia.index; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.Objects; + +import org.apache.maven.doxia.markup.Markup; +import org.apache.maven.doxia.sink.Sink; /** - *

IndexEntry class.

+ * Representing the index tree within a document with the most important metadata per entry. + * Currently this only contains entries for sections, but in the future may be extended, therefore it + * is recommended to use {@link #getType()} to filter out irrelevant entries. * * @author Trygve Laugstøl */ @@ -38,6 +45,11 @@ public class IndexEntry { */ private String id; + /** + * true if there is already an anchor for this + */ + private boolean hasAnchor; + /** * The entry title. */ @@ -48,13 +60,50 @@ public class IndexEntry { */ private List childEntries = new ArrayList<>(); + public enum Type { + /** + * Used for unknown types but also for the root entry + */ + UNKNOWN(), + SECTION_1(Sink.SECTION_LEVEL_1), + SECTION_2(Sink.SECTION_LEVEL_2), + SECTION_3(Sink.SECTION_LEVEL_3), + SECTION_4(Sink.SECTION_LEVEL_4), + SECTION_5(Sink.SECTION_LEVEL_5), + SECTION_6(), + DEFINED_TERM(), + FIGURE(), + TABLE(); + + private final int sectionLevel; + + Type() { + this(-1); + } + + Type(int sectionLevel) { + this.sectionLevel = sectionLevel; + } + + static Type fromSectionLevel(int level) { + if (level < Sink.SECTION_LEVEL_1 || level > Sink.SECTION_LEVEL_5) { + throw new IllegalArgumentException("Level must be between " + Sink.SECTION_LEVEL_1 + " and " + + Sink.SECTION_LEVEL_5 + " but is " + level); + } + return Arrays.stream(Type.values()) + .filter(t -> level == t.sectionLevel) + .findAny() + .orElseThrow(() -> new IllegalStateException("Could not find enum for sectionLevel " + level)); + } + }; + /** - * System-dependent EOL. + * The type of the entry, one of the types defined by {@link IndexingSink} */ - private static final String EOL = System.getProperty("line.separator"); + private final Type type; /** - * Constructor. + * Constructor for root entry. * * @param newId The id. May be null. */ @@ -69,12 +118,24 @@ public IndexEntry(String newId) { * @param newId The id. May be null. */ public IndexEntry(IndexEntry newParent, String newId) { + this(newParent, newId, Type.UNKNOWN); + } + + /** + * Constructor. + * + * @param newParent The parent. May be null. + * @param newId The id. May be null. + * @param + */ + public IndexEntry(IndexEntry newParent, String newId, Type type) { this.parent = newParent; this.id = newId; if (parent != null) { parent.childEntries.add(this); } + this.type = type; } /** @@ -105,6 +166,34 @@ protected void setId(String id) { this.id = id; } + /** + * Returns the type of this entry. Is one of the types defined by {@link IndexingSink}. + * @return the type of this entry + * @since 2.0.0 + */ + public Type getType() { + return type; + } + + /** Set if the entry's id already has an anchor in the underlying document. + * + * @param hasAnchor {@true} if the id already has an anchor. + * @since 2.0.0 + */ + public void setAnchor(boolean hasAnchor) { + this.hasAnchor = hasAnchor; + } + + /** + * Returns if the entry's id already has an anchor in the underlying document. + * @return {@code true} if the id already has an anchor otherwise {@code false}. + * + * @since 2.0.0 + */ + public boolean hasAnchor() { + return hasAnchor; + } + /** * Returns the title. * @@ -266,7 +355,7 @@ public String toString(int depth) { message.append(", title: ").append(title); } - message.append(EOL); + message.append(Markup.EOL); StringBuilder indent = new StringBuilder(); @@ -280,4 +369,29 @@ public String toString(int depth) { return message.toString(); } + + @Override + public int hashCode() { + return Objects.hash(childEntries, hasAnchor, id, parent, title, type); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + IndexEntry other = (IndexEntry) obj; + return Objects.equals(childEntries, other.childEntries) + && hasAnchor == other.hasAnchor + && Objects.equals(id, other.id) + && Objects.equals(parent, other.parent) + && Objects.equals(title, other.title) + && type == other.type; + } } diff --git a/doxia-core/src/main/java/org/apache/maven/doxia/index/IndexingSink.java b/doxia-core/src/main/java/org/apache/maven/doxia/index/IndexingSink.java index fc2a2d7b5..2a3acebde 100644 --- a/doxia-core/src/main/java/org/apache/maven/doxia/index/IndexingSink.java +++ b/doxia-core/src/main/java/org/apache/maven/doxia/index/IndexingSink.java @@ -23,49 +23,25 @@ import java.util.Stack; import java.util.concurrent.atomic.AtomicInteger; +import org.apache.maven.doxia.index.IndexEntry.Type; +import org.apache.maven.doxia.sink.Sink; import org.apache.maven.doxia.sink.SinkEventAttributes; +import org.apache.maven.doxia.sink.impl.BufferingSinkProxyFactory; +import org.apache.maven.doxia.sink.impl.BufferingSinkProxyFactory.BufferingSink; import org.apache.maven.doxia.sink.impl.SinkAdapter; import org.apache.maven.doxia.util.DoxiaUtils; /** - * A sink implementation for index. + * A sink wrapper for populating an index tree for particular elements in a document. + * Currently this only generates {@link IndexEntry} objects for sections. * * @author Trygve Laugstøl * @author Vincent Siveton */ -public class IndexingSink extends SinkAdapter { - /** Section 1. */ - private static final int TYPE_SECTION_1 = 1; - - /** Section 2. */ - private static final int TYPE_SECTION_2 = 2; - - /** Section 3. */ - private static final int TYPE_SECTION_3 = 3; - - /** Section 4. */ - private static final int TYPE_SECTION_4 = 4; - - /** Section 5. */ - private static final int TYPE_SECTION_5 = 5; - - /** Defined term. */ - private static final int TYPE_DEFINED_TERM = 6; - - /** Figure. */ - private static final int TYPE_FIGURE = 7; - - /** Table. */ - private static final int TYPE_TABLE = 8; - - /** Title. */ - private static final int TITLE = 9; +public class IndexingSink extends org.apache.maven.doxia.sink.impl.SinkWrapper { /** The current type. */ - private int type; - - /** The current title. */ - private String title; + private Type type; /** The stack. */ private final Stack stack; @@ -76,26 +52,56 @@ public class IndexingSink extends SinkAdapter { */ private final Map usedIds; + private final IndexEntry rootEntry; + + private boolean isComplete; + private boolean isTitle; + /** + * @deprecated legacy constructor, use {@link #IndexingSink(Sink)} with {@link SinkAdapter} as argument and call {@link #getRootEntry()} to retrieve the index tree afterwards. + */ + @Deprecated + public IndexingSink(IndexEntry rootEntry) { + this(rootEntry, new SinkAdapter()); + } + + public IndexingSink(Sink delegate) { + this(new IndexEntry("index"), delegate); + } + /** * Default constructor. - * - * @param sectionEntry The first index entry. */ - public IndexingSink(IndexEntry sectionEntry) { + private IndexingSink(IndexEntry rootEntry, Sink delegate) { + super(delegate); + this.rootEntry = rootEntry; stack = new Stack<>(); - stack.push(sectionEntry); + stack.push(rootEntry); usedIds = new HashMap<>(); - usedIds.put(sectionEntry.getId(), new AtomicInteger()); - init(); + usedIds.put(rootEntry.getId(), new AtomicInteger()); + this.type = Type.UNKNOWN; } + /** + * This should only be called once the sink is closed. + * Before that the tree might not be complete. + * @return the tree of entries starting from the root + * @throws IllegalStateException in case the sink was not closed yet + */ + public IndexEntry getRootEntry() { + if (!isComplete) { + throw new IllegalStateException( + "The sink has not been closed yet, i.e. the index tree is not complete yet"); + } + return rootEntry; + } /** *

Getter for the field title.

+ * Shortcut for {@link #getRootEntry()} followed by {@link IndexEntry#getTitle()}. * * @return the title */ public String getTitle() { - return title; + return rootEntry.getTitle(); } // ---------------------------------------------------------------------- @@ -104,60 +110,47 @@ public String getTitle() { @Override public void title(SinkEventAttributes attributes) { - this.type = TITLE; + isTitle = true; + super.title(attributes); } @Override - public void section(int level, SinkEventAttributes attributes) { - pushNewEntry(); + public void title_() { + isTitle = false; + super.title_(); } @Override - public void section_(int level) { - pop(); + public void section(int level, SinkEventAttributes attributes) { + super.section(level, attributes); + this.type = IndexEntry.Type.fromSectionLevel(level); + pushNewEntry(type); } @Override - public void sectionTitle(int level, SinkEventAttributes attributes) { - this.type = level; + public void section_(int level) { + pop(); + super.section_(level); } @Override public void sectionTitle_(int level) { - this.type = 0; + indexEntryComplete(); + super.sectionTitle_(level); } - @Override - public void title_() { - this.type = 0; - } - - // public void definedTerm() - // { - // type = TYPE_DEFINED_TERM; - // } - // - // public void figureCaption() - // { - // type = TYPE_FIGURE; - // } - // - // public void tableCaption() - // { - // type = TYPE_TABLE; - // } - @Override public void text(String text, SinkEventAttributes attributes) { + if (isTitle) { + rootEntry.setTitle(text); + return; + } switch (this.type) { - case TITLE: - this.title = text; - break; - case TYPE_SECTION_1: - case TYPE_SECTION_2: - case TYPE_SECTION_3: - case TYPE_SECTION_4: - case TYPE_SECTION_5: + case SECTION_1: + case SECTION_2: + case SECTION_3: + case SECTION_4: + case SECTION_5: // ----------------------------------------------------------------------- // Sanitize the id. The most important step is to remove any blanks // ----------------------------------------------------------------------- @@ -169,16 +162,43 @@ public void text(String text, SinkEventAttributes attributes) { title = title.replaceAll("[\\r\\n]+", ""); entry.setTitle(title); - entry.setId(getUniqueId(DoxiaUtils.encodeId(title))); - + setEntryId(entry, title); break; - // Dunno how to handle these yet - case TYPE_DEFINED_TERM: - case TYPE_FIGURE: - case TYPE_TABLE: + // Dunno how to handle others yet default: break; } + super.text(text, attributes); + } + + @Override + public void anchor(String name, SinkEventAttributes attributes) { + parseAnchor(name); + super.anchor(name, attributes); + } + + private boolean parseAnchor(String name) { + switch (type) { + case SECTION_1: + case SECTION_2: + case SECTION_3: + case SECTION_4: + case SECTION_5: + IndexEntry entry = stack.lastElement(); + entry.setAnchor(true); + setEntryId(entry, name); + break; + default: + return false; + } + return true; + } + + private void setEntryId(IndexEntry entry, String id) { + if (entry.getId() != null) { + usedIds.remove(entry.getId()); + } + entry.setId(getUniqueId(DoxiaUtils.encodeId(id))); } /** @@ -199,15 +219,34 @@ String getUniqueId(String id) { return uniqueId; } + void indexEntryComplete() { + this.type = Type.UNKNOWN; + // remove buffering sink from pipeline + BufferingSink bufferingSink = BufferingSinkProxyFactory.castAsBufferingSink(getWrappedSink()); + setWrappedSink(bufferingSink.getBufferedSink()); + + onIndexEntry(stack.peek()); + + // flush the buffer afterwards + bufferingSink.flush(); + } + /** - * Creates and pushes a new IndexEntry onto the top of this stack. + * Called at the beginning of each entry (once all metadata about it is collected). + * The events for the metadata are buffered and only flushed after this method was called. + * @param entry the newly collected entry */ - public void pushNewEntry() { - IndexEntry entry = new IndexEntry(peek(), ""); + protected void onIndexEntry(IndexEntry entry) {} + /** + * Creates and pushes a new IndexEntry onto the top of this stack. + */ + private void pushNewEntry(Type type) { + IndexEntry entry = new IndexEntry(peek(), "", type); entry.setTitle(""); - stack.push(entry); + // now buffer everything till the next index metadata is complete + setWrappedSink(new BufferingSinkProxyFactory().createWrapper(getWrappedSink())); } /** @@ -235,20 +274,9 @@ public IndexEntry peek() { return stack.peek(); } - /** - * {@inheritDoc} - */ + @Override public void close() { super.close(); - - init(); - } - - /** - * {@inheritDoc} - */ - protected void init() { - this.type = 0; - this.title = null; + isComplete = true; } } diff --git a/doxia-core/src/main/java/org/apache/maven/doxia/macro/MacroRequest.java b/doxia-core/src/main/java/org/apache/maven/doxia/macro/MacroRequest.java index 55ed4cf1c..7a5b3d631 100644 --- a/doxia-core/src/main/java/org/apache/maven/doxia/macro/MacroRequest.java +++ b/doxia-core/src/main/java/org/apache/maven/doxia/macro/MacroRequest.java @@ -44,7 +44,7 @@ public class MacroRequest { *

Constructor for MacroRequest.

* * @param sourceContent a {@link java.lang.String} object. - * @param parser a {@link org.apache.maven.doxia.parser.AbstractParser} object. + * @param parser a new {@link org.apache.maven.doxia.parser.AbstractParser} object acting as secondary parser. * @param param a {@link java.util.Map} object. * @param basedir a {@link java.io.File} object. */ @@ -106,7 +106,7 @@ public String getSourceContent() { /** *

getParser.

* - * @return a {@link org.apache.maven.doxia.parser.Parser} object. + * @return a {@link org.apache.maven.doxia.parser.Parser} object. This is a new secondary parser. */ public Parser getParser() { return (Parser) getParameter(PARAM_PARSER); diff --git a/doxia-core/src/main/java/org/apache/maven/doxia/macro/toc/TocMacro.java b/doxia-core/src/main/java/org/apache/maven/doxia/macro/toc/TocMacro.java index 61c9e18c4..d07771c2d 100644 --- a/doxia-core/src/main/java/org/apache/maven/doxia/macro/toc/TocMacro.java +++ b/doxia-core/src/main/java/org/apache/maven/doxia/macro/toc/TocMacro.java @@ -31,6 +31,7 @@ import org.apache.maven.doxia.parser.ParseException; import org.apache.maven.doxia.parser.Parser; import org.apache.maven.doxia.sink.Sink; +import org.apache.maven.doxia.sink.impl.SinkAdapter; import org.apache.maven.doxia.util.DoxiaUtils; /** @@ -102,15 +103,16 @@ public void execute(Sink sink, MacroRequest request) throws MacroExecutionExcept return; } - IndexEntry index = new IndexEntry("index"); - IndexingSink tocSink = new IndexingSink(index); - + IndexingSink tocSink = new IndexingSink(new SinkAdapter()); try { parser.parse(new StringReader(source), tocSink); } catch (ParseException e) { throw new MacroExecutionException(e); + } finally { + tocSink.close(); } + IndexEntry index = tocSink.getRootEntry(); if (index.getChildEntries().size() > 0) { sink.list(getAttributesFromMap(request.getParameters())); diff --git a/doxia-core/src/main/java/org/apache/maven/doxia/parser/AbstractParser.java b/doxia-core/src/main/java/org/apache/maven/doxia/parser/AbstractParser.java index a55f1aa13..fcab7bb24 100644 --- a/doxia-core/src/main/java/org/apache/maven/doxia/parser/AbstractParser.java +++ b/doxia-core/src/main/java/org/apache/maven/doxia/parser/AbstractParser.java @@ -36,6 +36,7 @@ import org.apache.maven.doxia.macro.manager.MacroManager; import org.apache.maven.doxia.macro.manager.MacroNotFoundException; import org.apache.maven.doxia.sink.Sink; +import org.apache.maven.doxia.sink.impl.CreateAnchorsForIndexEntriesFactory; import org.apache.maven.doxia.sink.impl.SinkWrapperFactory; import org.apache.maven.doxia.sink.impl.SinkWrapperFactoryComparator; @@ -63,6 +64,8 @@ public abstract class AbstractParser implements Parser { */ private boolean emitComments = true; + private boolean emitAnchors = false; + private static final String DOXIA_VERSION; static { @@ -112,6 +115,16 @@ public boolean isEmitComments() { return emitComments; } + @Override + public boolean isEmitAnchorsForIndexableEntries() { + return emitAnchors; + } + + @Override + public void setEmitAnchorsForIndexableEntries(boolean emitAnchors) { + this.emitAnchors = emitAnchors; + } + /** * Execute a macro on the given sink. * @@ -230,6 +243,9 @@ public Collection getSinkWrapperFactories() { effectiveSinkWrapperFactories.addAll(automaticallyRegisteredSinkWrapperFactories); } effectiveSinkWrapperFactories.addAll(manuallyRegisteredSinkWrapperFactories); + if (emitAnchors) { + effectiveSinkWrapperFactories.add(new CreateAnchorsForIndexEntriesFactory()); + } return effectiveSinkWrapperFactories; } diff --git a/doxia-core/src/main/java/org/apache/maven/doxia/parser/Parser.java b/doxia-core/src/main/java/org/apache/maven/doxia/parser/Parser.java index da1658955..228bc3577 100644 --- a/doxia-core/src/main/java/org/apache/maven/doxia/parser/Parser.java +++ b/doxia-core/src/main/java/org/apache/maven/doxia/parser/Parser.java @@ -21,6 +21,7 @@ import java.io.Reader; import java.util.Collection; +import org.apache.maven.doxia.index.IndexingSink; import org.apache.maven.doxia.sink.Sink; import org.apache.maven.doxia.sink.impl.SinkWrapperFactory; @@ -99,4 +100,20 @@ public interface Parser { * @since 2.0.0 */ Collection getSinkWrapperFactories(); + + /** + * Determines whether to automatically generate anchors for each index entry found by {@link IndexingSink} or not. + * By default no anchors are generated. + * + * @param emitAnchors {@code true} to emit anchors otherwise {@code false} (the default) + * @since 2.0.0 + */ + void setEmitAnchorsForIndexableEntries(boolean emitAnchors); + + /** + * Returns whether anchors are automatically generated for each index entry found by {@link IndexingSink} or not. + * @return {@code true} if anchors are emitted otherwise {@code false} + * @since 2.0.0 + */ + boolean isEmitAnchorsForIndexableEntries(); } diff --git a/doxia-core/src/main/java/org/apache/maven/doxia/sink/impl/CreateAnchorsForIndexEntries.java b/doxia-core/src/main/java/org/apache/maven/doxia/sink/impl/CreateAnchorsForIndexEntries.java new file mode 100644 index 000000000..9998b99dc --- /dev/null +++ b/doxia-core/src/main/java/org/apache/maven/doxia/sink/impl/CreateAnchorsForIndexEntries.java @@ -0,0 +1,44 @@ +/* + * 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.sink.impl; + +import org.apache.maven.doxia.index.IndexEntry; +import org.apache.maven.doxia.index.IndexingSink; +import org.apache.maven.doxia.macro.toc.TocMacro; +import org.apache.maven.doxia.sink.Sink; + +/** + * Sink wrapper which emits anchors for each entry detected by the underlying {@link IndexingSink}. + * It only creates an anchor if there is no accompanying anchor detected for the according entry. + * @see TocMacro + */ +public class CreateAnchorsForIndexEntries extends IndexingSink { + + public CreateAnchorsForIndexEntries(Sink delegate) { + super(delegate); + } + + @Override + protected void onIndexEntry(IndexEntry entry) { + if (!entry.hasAnchor()) { + getWrappedSink().anchor(entry.getId()); + getWrappedSink().anchor_(); + } + } +} diff --git a/doxia-core/src/main/java/org/apache/maven/doxia/sink/impl/CreateAnchorsForIndexEntriesFactory.java b/doxia-core/src/main/java/org/apache/maven/doxia/sink/impl/CreateAnchorsForIndexEntriesFactory.java new file mode 100644 index 000000000..d6e4ebdca --- /dev/null +++ b/doxia-core/src/main/java/org/apache/maven/doxia/sink/impl/CreateAnchorsForIndexEntriesFactory.java @@ -0,0 +1,38 @@ +/* + * 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.sink.impl; + +import org.apache.maven.doxia.sink.Sink; + +public class CreateAnchorsForIndexEntriesFactory implements SinkWrapperFactory { + + public CreateAnchorsForIndexEntriesFactory() { + super(); + } + + @Override + public Sink createWrapper(Sink sink) { + return new CreateAnchorsForIndexEntries(sink); + } + + @Override + public int getPriority() { + return 0; + } +} diff --git a/doxia-core/src/main/java/org/apache/maven/doxia/sink/impl/UniqueAnchorNamesValidator.java b/doxia-core/src/main/java/org/apache/maven/doxia/sink/impl/UniqueAnchorNamesValidator.java new file mode 100644 index 000000000..b44ab4741 --- /dev/null +++ b/doxia-core/src/main/java/org/apache/maven/doxia/sink/impl/UniqueAnchorNamesValidator.java @@ -0,0 +1,52 @@ +/* + * 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.sink.impl; + +import java.util.HashSet; +import java.util.Set; + +import org.apache.maven.doxia.sink.Sink; +import org.apache.maven.doxia.sink.SinkEventAttributes; + +/** + * Validates that each anchor name only appears once per document. Otherwise fails with an {@link IllegalStateException}. + */ +public class UniqueAnchorNamesValidator extends SinkWrapper { + + private final Set usedAnchorNames; + + public UniqueAnchorNamesValidator(Sink sink) { + super(sink); + usedAnchorNames = new HashSet<>(); + } + + @Override + public void anchor(String name, SinkEventAttributes attributes) { + // assume that other anchor method signature calls this method under the hood in all relevant sink + // implementations + super.anchor(name, attributes); + enforceUniqueAnchor(name); + } + + private void enforceUniqueAnchor(String name) { + if (!usedAnchorNames.add(name)) { + throw new IllegalStateException("Anchor name \"" + name + "\" used more than once"); + } + } +} diff --git a/doxia-core/src/main/java/org/apache/maven/doxia/sink/impl/UniqueAnchorNamesValidatorFactory.java b/doxia-core/src/main/java/org/apache/maven/doxia/sink/impl/UniqueAnchorNamesValidatorFactory.java new file mode 100644 index 000000000..fa7e6a57f --- /dev/null +++ b/doxia-core/src/main/java/org/apache/maven/doxia/sink/impl/UniqueAnchorNamesValidatorFactory.java @@ -0,0 +1,38 @@ +/* + * 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.sink.impl; + +import javax.inject.Named; + +import org.apache.maven.doxia.sink.Sink; + +@Named("unique-anchors-validator") +public class UniqueAnchorNamesValidatorFactory implements SinkWrapperFactory { + + @Override + public Sink createWrapper(Sink sink) { + return new UniqueAnchorNamesValidator(sink); + } + + @Override + public int getPriority() { + // should come last (after potential preprocessing/modification of anchor names) + return Integer.MIN_VALUE; + } +} diff --git a/doxia-core/src/test/java/org/apache/maven/doxia/index/IndexEntryTest.java b/doxia-core/src/test/java/org/apache/maven/doxia/index/IndexEntryTest.java index a94a37f8e..b2363d909 100644 --- a/doxia-core/src/test/java/org/apache/maven/doxia/index/IndexEntryTest.java +++ b/doxia-core/src/test/java/org/apache/maven/doxia/index/IndexEntryTest.java @@ -18,9 +18,12 @@ */ package org.apache.maven.doxia.index; +import org.apache.maven.doxia.index.IndexEntry.Type; +import org.apache.maven.doxia.sink.Sink; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; /** @@ -34,35 +37,42 @@ public class IndexEntryTest { public void testIndexEntry() { IndexEntry root = new IndexEntry(null); - assertIndexEntry(root, null, 0, null, null); + assertIndexEntry(root, Type.UNKNOWN, null, 0, null, null); // ----------------------------------------------------------------------- // Chapter 1 // ----------------------------------------------------------------------- - IndexEntry chapter1 = new IndexEntry(root, "chapter-1"); + IndexEntry chapter1 = new IndexEntry(root, "chapter-1", Type.SECTION_1); - assertIndexEntry(root, null, 1, null, null); + assertIndexEntry(root, Type.UNKNOWN, null, 1, null, null); - assertIndexEntry(chapter1, root, 0, null, null); + assertIndexEntry(chapter1, Type.SECTION_1, root, 0, null, null); // ----------------------------------------------------------------------- // Chapter 2 // ----------------------------------------------------------------------- - IndexEntry chapter2 = new IndexEntry(root, "chapter-2"); + IndexEntry chapter2 = new IndexEntry(root, "chapter-2", Type.SECTION_1); - assertIndexEntry(root, null, 2, null, null); + assertIndexEntry(root, Type.UNKNOWN, null, 2, null, null); - assertIndexEntry(chapter1, root, 0, null, chapter2); - assertIndexEntry(chapter2, root, 0, chapter1, null); + assertIndexEntry(chapter1, Type.SECTION_1, root, 0, null, chapter2); + assertIndexEntry(chapter2, Type.SECTION_1, root, 0, chapter1, null); chapter2.setTitle("Title 2"); assertTrue(chapter2.toString().contains("Title 2")); } private void assertIndexEntry( - IndexEntry entry, IndexEntry parent, int childCount, IndexEntry prevEntry, IndexEntry nextEntry) { + IndexEntry entry, + Type type, + IndexEntry parent, + int childCount, + IndexEntry prevEntry, + IndexEntry nextEntry) { + assertEquals(type, entry.getType()); + assertEquals(parent, entry.getParent()); assertEquals(childCount, entry.getChildEntries().size()); @@ -71,4 +81,11 @@ private void assertIndexEntry( assertEquals(nextEntry, entry.getNextEntry()); } + + @Test + public void testTypeFromSectionLevel() { + assertThrows(IllegalArgumentException.class, () -> Type.fromSectionLevel(0)); + assertEquals(Type.SECTION_3, Type.fromSectionLevel(Sink.SECTION_LEVEL_3)); + assertThrows(IllegalArgumentException.class, () -> Type.fromSectionLevel(7)); + } } diff --git a/doxia-core/src/test/java/org/apache/maven/doxia/macro/toc/TocMacroTest.java b/doxia-core/src/test/java/org/apache/maven/doxia/macro/toc/TocMacroTest.java index 3c350e3f5..a8cc291f2 100644 --- a/doxia-core/src/test/java/org/apache/maven/doxia/macro/toc/TocMacroTest.java +++ b/doxia-core/src/test/java/org/apache/maven/doxia/macro/toc/TocMacroTest.java @@ -26,14 +26,21 @@ import org.apache.maven.doxia.macro.MacroExecutionException; import org.apache.maven.doxia.macro.MacroRequest; +import org.apache.maven.doxia.markup.Markup; +import org.apache.maven.doxia.parser.AbstractParserTest; +import org.apache.maven.doxia.parser.ParseException; import org.apache.maven.doxia.parser.Xhtml5BaseParser; +import org.apache.maven.doxia.sink.Sink; +import org.apache.maven.doxia.sink.impl.CreateAnchorsForIndexEntriesFactory; import org.apache.maven.doxia.sink.impl.SinkEventAttributeSet; import org.apache.maven.doxia.sink.impl.SinkEventElement; import org.apache.maven.doxia.sink.impl.SinkEventTestingSink; import org.apache.maven.doxia.sink.impl.Xhtml5BaseSink; import org.junit.jupiter.api.Test; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; /** * Test toc macro. @@ -96,8 +103,10 @@ public void testExecute() throws MacroExecutionException { assertEquals("list_", (it.next()).getName()); assertFalse(it.hasNext()); - // test parameters + // no wrapper factories should be registered by default + assertEquals(0, parser.getSinkWrapperFactories().size()); + // test parameters parser = new Xhtml5BaseParser(); macroParameters.put("section", "2"); macroParameters.put("fromDepth", "1"); @@ -162,4 +171,35 @@ public void testTocStyle() throws MacroExecutionException { assertTrue(out.toString().contains("h12")); assertTrue(out.toString().contains("h2")); } + + @Test + public void testGenerateAnchors() throws ParseException, MacroExecutionException { + String sourceContent = "

1 Headline

"; + File basedir = new File(""); + Xhtml5BaseParser parser = new Xhtml5BaseParser(); + MacroRequest request = new MacroRequest(sourceContent, parser, new HashMap<>(), basedir); + TocMacro macro = new TocMacro(); + SinkEventTestingSink sink = new SinkEventTestingSink(); + + macro.execute(sink, request); + parser.parse(sourceContent, sink); + + Iterator it = sink.getEventList().iterator(); + AbstractParserTest.assertSinkStartsWith(it, "list", "listItem"); + SinkEventElement link = it.next(); + assertEquals("link", link.getName()); + String actualLinkTarget = (String) link.getArgs()[0]; + AbstractParserTest.assertSinkEquals(it.next(), "text", "1 Headline", null); + AbstractParserTest.assertSinkEquals( + it, "link_", "listItem_", "list_", "section1", "sectionTitle1", "text", "sectionTitle1_"); + + // check html output as well (without the actual TOC) + StringWriter out = new StringWriter(); + Sink sink2 = new Xhtml5BaseSink(out); + parser.addSinkWrapperFactory(new CreateAnchorsForIndexEntriesFactory()); + parser.parse(sourceContent, sink2); + assertEquals( + "
" + Markup.EOL + "

1 Headline

", + out.toString()); + } } diff --git a/doxia-modules/doxia-module-apt/src/test/resources/test.apt b/doxia-modules/doxia-module-apt/src/test/resources/test.apt index 0fcb00cf8..87bf17a8a 100644 --- a/doxia-modules/doxia-module-apt/src/test/resources/test.apt +++ b/doxia-modules/doxia-module-apt/src/test/resources/test.apt @@ -15,7 +15,7 @@ Section title * Sub-section title -** Sub-sub-section title +** {Sub-sub-section title} *** Sub-sub-sub-section title