AsciiDoc.java
/*
* The contents of this file are subject to the terms of the Common Development and
* Distribution License (the License). You may not use this file except in compliance with the
* License.
*
* You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
* specific language governing permission and limitations under the License.
*
* When distributing Covered Software, include this CDDL Header Notice in each file and include
* the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
* Header, with the fields enclosed by brackets [] replaced by your own identifying
* information: "Portions copyright [year] [name of copyright owner]".
*
* Copyright 2016 ForgeRock AS.
*/
package org.forgerock.api.markup.asciidoc;
import static org.forgerock.api.markup.asciidoc.AsciiDocSymbols.*;
import static org.forgerock.api.util.ValidationUtil.containsWhitespace;
import static org.forgerock.api.util.ValidationUtil.isEmpty;
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.forgerock.util.Reject.checkNotNull;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Root builder for AsciiDoc markup. All operations may be applied at the current linear position within the
* document being built, such that markup that must appear at the top should be added first.
* <p>
* This class is not thread-safe.
* </p>
*/
public final class AsciiDoc {
// TODO http://asciidoctor.org/docs/user-manual/#preventing-substitutions
/**
* Regex for finding <a href="http://asciidoctor.org/docs/user-manual/#include-directive">Include</a>-directives,
* where group 1 contains the path-value.
*
* @see #include(String...)
*/
public static final Pattern INCLUDE_PATTERN = Pattern.compile("include[:]{2}([^\\[]+)\\[\\]");
/**
* Underscore-character is used as the namespace-part delimiter.
*/
private static final String NAMESPACE_DELIMITER = "_";
/**
* Characters that must be replaced/removed for a filename to be a POSIX "Fully portable filename"
* <a href="https://en.wikipedia.org/wiki/Filename#Reserved_characters_and_words">[ref]</a>.
*/
private static final Pattern POSIX_FILENAME_REPLACEMENT_PATTERN = Pattern.compile("^[-]|[^A-Za-z0-9._-]");
/**
* Pattern for replacing multiple underscores with a single underscore.
*/
private static final Pattern SQUASH_UNDERSCORES_PATTERN = Pattern.compile("[_]{2,}");
private final StringBuilder builder;
private AsciiDoc() {
builder = new StringBuilder();
}
/**
* Creates a new builder instance.
*
* @return builder
*/
public static AsciiDoc asciiDoc() {
return new AsciiDoc();
}
/**
* Prefixes the given line-of-content with an AsciiDoc symbol.
*
* @param symbol AsciiDoc symbol
* @param content Content
* @return Doc builder
*/
private AsciiDoc line(final AsciiDocSymbols symbol, final String content) {
if (isEmpty(content)) {
throw new AsciiDocException("content required");
}
builder.append(NEWLINE).append(checkNotNull(symbol)).append(content).append(NEWLINE);
return this;
}
/**
* Surrounds the given content-block with block symbols.
*
* @param symbol AsciiDoc symbol
* @param content Content
* @return Doc builder
*/
private AsciiDoc block(final AsciiDocSymbols symbol, final String content) {
if (isEmpty(content)) {
throw new AsciiDocException("content required");
}
builder.append(checkNotNull(symbol)).append(NEWLINE)
.append(content).append(NEWLINE)
.append(checkNotNull(symbol)).append(NEWLINE);
return this;
}
/**
* Inserts a UNIX newline character, where two adjacent newlines will create a new
* <a href="http://asciidoctor.org/docs/user-manual/#paragraph">paragraph</a>.
* As a best-practice, they suggest one-sentence-per-line style.
*
* @return builder
*/
public AsciiDoc newline() {
builder.append(NEWLINE);
return this;
}
/**
* Inserts raw text (may contain markup or only whitespace).
*
* @param text Raw text/markup
* @return builder
*/
public AsciiDoc rawText(final String text) {
if (text == null) {
throw new AsciiDocException("text required");
}
builder.append(text);
return this;
}
/**
* Inserts raw line (may contain markup), and will insert one newline-characters above and below, if those
* newlines do not already exist.
*
* @param text Raw text/markup
* @return builder
*/
public AsciiDoc rawLine(final String text) {
if (isEmpty(text)) {
throw new AsciiDocException("text required");
}
final int newlinesAbove = requireTrailingNewlines(1, builder);
if (newlinesAbove == 1) {
builder.append(NEWLINE);
}
builder.append(text);
final int newlinesBelow = requireTrailingNewlines(1, text);
if (newlinesBelow == 1) {
builder.append(NEWLINE);
}
return this;
}
/**
* Inserts raw paragraph (may contain markup), and will insert two newline-characters above and below, if those
* newlines do not already exist [<a href="http://asciidoctor.org/docs/user-manual/#paragraph">ref</a>].
*
* @param text Raw text/markup
* @return builder
*/
public AsciiDoc rawParagraph(final String text) {
if (isEmpty(text)) {
throw new AsciiDocException("text required");
}
int newlinesAbove = requireTrailingNewlines(2, builder);
while (--newlinesAbove > -1) {
builder.append(NEWLINE);
}
builder.append(text);
int newlinesBelow = requireTrailingNewlines(2, text);
while (--newlinesBelow > -1) {
builder.append(NEWLINE);
}
return this;
}
/**
* Checks for a minimum-number of trailing newline-characters.
*
* @param newlines Minimum number of required, trailing newline-characters (1 or higher)
* @param text Text to check
* @return Number of newline-characters that need to be added to the end of the text
*/
private static int requireTrailingNewlines(int newlines, final CharSequence text) {
if (newlines < 0) {
throw new IllegalArgumentException("newlines must be positive");
}
for (int i = 0; newlines > 0; ++i) {
if (text.length() > i && text.charAt(text.length() - (i + 1)) == '\n') {
--newlines;
} else {
break;
}
}
return newlines;
}
/**
* Inserts bold text.
*
* @param text Text to make bold
* @return builder
*/
public AsciiDoc boldText(final String text) {
if (isEmpty(text)) {
throw new AsciiDocException("text required");
}
builder.append(BOLD).append(text).append(BOLD);
return this;
}
/**
* Inserts italic text.
*
* @param text Text to make bold
* @return Doc builder
*/
public AsciiDoc italic(final String text) {
if (isEmpty(text)) {
throw new AsciiDocException("text required");
}
builder.append(ITALIC).append(text).append(ITALIC);
return this;
}
/**
* Inserts monospaced (e.g., code) text.
*
* @param text Text to make monospaced
* @return Doc builder
*/
public AsciiDoc mono(final String text) {
if (isEmpty(text)) {
throw new AsciiDocException("text required");
}
builder.append(MONO).append(text).append(MONO);
return this;
}
/**
* Inserts a document title.
*
* @param title Document title
* @return Doc builder
*/
public AsciiDoc documentTitle(final String title) {
return line(AsciiDocSymbols.DOC_TITLE, title);
}
/**
* Inserts a block title.
*
* @param title Block title
* @return Doc builder
*/
public AsciiDoc blockTitle(final String title) {
return line(AsciiDocSymbols.BLOCK_TITLE, title);
}
/**
* Inserts a section title, at a given level.
*
* @param title Section title
* @param level Section level [1-5]
* @return Doc builder
*/
public AsciiDoc sectionTitle(final String title, final int level) {
final AsciiDocSymbols symbol;
// @Checkstyle:off
switch (level) {
case 1:
return line(AsciiDocSymbols.SECTION_TITLE_1, title);
case 2:
return line(AsciiDocSymbols.SECTION_TITLE_2, title);
case 3:
return line(AsciiDocSymbols.SECTION_TITLE_3, title);
case 4:
return line(AsciiDocSymbols.SECTION_TITLE_4, title);
case 5:
return line(AsciiDocSymbols.SECTION_TITLE_5, title);
default:
throw new AsciiDocException("Unsupported section-level: " + level);
}
// @Checkstyle:on
}
/**
* Inserts a section title, level 1.
*
* @param title Section title
* @return Doc builder
*/
public AsciiDoc sectionTitle1(final String title) {
return line(AsciiDocSymbols.SECTION_TITLE_1, title);
}
/**
* Inserts a section title, level 2.
*
* @param title Section title
* @return Doc builder
*/
public AsciiDoc sectionTitle2(final String title) {
return line(AsciiDocSymbols.SECTION_TITLE_2, title);
}
/**
* Inserts a section title, level 3.
*
* @param title Section title
* @return Doc builder
*/
public AsciiDoc sectionTitle3(final String title) {
return line(AsciiDocSymbols.SECTION_TITLE_3, title);
}
/**
* Inserts a section title, level 4.
*
* @param title Section title
* @return Doc builder
*/
public AsciiDoc sectionTitle4(final String title) {
return line(AsciiDocSymbols.SECTION_TITLE_4, title);
}
/**
* Inserts a section title, level 5.
*
* @param title Section title
* @return Doc builder
*/
public AsciiDoc sectionTitle5(final String title) {
return line(AsciiDocSymbols.SECTION_TITLE_5, title);
}
/**
* Inserts an example-block.
*
* @param content Content
* @return Doc builder
*/
public AsciiDoc exampleBlock(final String content) {
return block(EXAMPLE, content);
}
/**
* Inserts a listing-block.
*
* @param content Content
* @return Doc builder
*/
public AsciiDoc listingBlock(final String content) {
return block(LISTING, content);
}
/**
* Inserts a listing-block, with the source-code type (e.g., java, json, etc.) noted for formatting purposes.
*
* @param content Content
* @param sourceType Type of source-code in the listing
* @return Doc builder
*/
public AsciiDoc listingBlock(final String content, final String sourceType) {
if (isEmpty(content) || isEmpty(sourceType)) {
throw new AsciiDocException("content and sourceType required");
}
builder.append("[source,")
.append(sourceType)
.append("]")
.append(NEWLINE);
return block(LISTING, content);
}
/**
* Inserts a literal-block.
*
* @param content Content
* @return Doc builder
*/
public AsciiDoc literalBlock(final String content) {
return block(LITERAL, content);
}
/**
* Inserts a pass-through-block.
*
* @param content Content
* @return Doc builder
*/
public AsciiDoc passthroughBlock(final String content) {
return block(PASSTHROUGH, content);
}
/**
* Inserts a sidebar-block.
*
* @param content Content
* @return Doc builder
*/
public AsciiDoc sidebarBlock(final String content) {
return block(SIDEBAR, content);
}
/**
* Inserts a cross-reference anchor.
*
* @param id Anchor ID
* @return Doc builder
*/
public AsciiDoc anchor(final String id) {
if (isEmpty(id)) {
throw new AsciiDocException("id required");
}
if (containsWhitespace(id)) {
throw new AsciiDocException("id contains whitespace");
}
builder.append(AsciiDocSymbols.ANCHOR_START)
.append(id)
.append(AsciiDocSymbols.ANCHOR_END);
return this;
}
/**
* Inserts a cross-reference anchor, with a custom
* <a href="http://asciidoctor.org/docs/user-manual/#anchordef">xreflabel</a>.
*
* @param id Anchor ID
* @param xreflabel Custom cross-reference link
* @return Doc builder
*/
public AsciiDoc anchor(final String id, final String xreflabel) {
if (isEmpty(id) || isEmpty(xreflabel)) {
throw new AsciiDocException("id and xreflabel required");
}
if (containsWhitespace(id)) {
throw new AsciiDocException("id contains whitespace");
}
builder.append(AsciiDocSymbols.ANCHOR_START)
.append(id)
.append(',').append(xreflabel)
.append(AsciiDocSymbols.ANCHOR_END);
return this;
}
/**
* Inserts a cross-reference link.
*
* @param anchorId Anchor ID
* @return Doc builder
*/
public AsciiDoc link(final String anchorId) {
if (isEmpty(anchorId)) {
throw new AsciiDocException("anchorId required");
}
if (containsWhitespace(anchorId)) {
throw new AsciiDocException("anchorId contains whitespace");
}
builder.append(AsciiDocSymbols.CROSS_REF_START)
.append(anchorId)
.append(AsciiDocSymbols.CROSS_REF_END);
return this;
}
/**
* Inserts a cross-reference link, with a custom
* <a href="http://asciidoctor.org/docs/user-manual/#anchordef">xreflabel</a>.
*
* @param anchorId Anchor ID
* @param xreflabel Custom cross-reference link
* @return Doc builder
*/
public AsciiDoc link(final String anchorId, final String xreflabel) {
if (isEmpty(anchorId) || isEmpty(xreflabel)) {
throw new AsciiDocException("anchorId and xreflabel required");
}
if (containsWhitespace(anchorId)) {
throw new AsciiDocException("anchorId contains whitespace");
}
builder.append(AsciiDocSymbols.CROSS_REF_START)
.append(anchorId)
.append(',').append(xreflabel)
.append(AsciiDocSymbols.CROSS_REF_END);
return this;
}
/**
* Inserts a line for an unordered list, at level 1 indentation.
*
* @param content Line of content
* @return Doc builder
*/
public AsciiDoc unorderedList1(final String content) {
line(UNORDERED_LIST_1, content);
return this;
}
/**
* Inserts a <a href="http://asciidoctor.org/docs/user-manual/#complex-list-content">list-continuation</a>,
* for adding complex formatted content to a list.
*
* @return Doc builder
*/
public AsciiDoc listContinuation() {
final int newlinesAbove = requireTrailingNewlines(1, builder);
if (newlinesAbove == 1) {
builder.append(NEWLINE);
}
builder.append(LIST_CONTINUATION);
return this;
}
/**
* Inserts a horizontal-rule divider.
*
* @return Doc builder
*/
public AsciiDoc horizontalRule() {
final int newlinesAbove = requireTrailingNewlines(1, builder);
if (newlinesAbove == 1) {
builder.append(NEWLINE);
}
builder.append(HORIZONTAL_RULE)
.append(NEWLINE);
return this;
}
/**
* Starts a table at the current position.
*
* @return Table builder
*/
public AsciiDocTable tableStart() {
return new AsciiDocTable(this, builder);
}
/**
* Inserts an include-directive, given a relative path to a file.
*
* @param path Relative path segments
* @return Doc builder
*/
public AsciiDoc include(final String... path) {
if (isEmpty(path)) {
throw new AsciiDocException("path required");
}
builder.append(INCLUDE);
builder.append(path[0]);
for (int i = 1; i < path.length; ++i) {
builder.append('/').append(path[i]);
}
builder.append("[]").append(NEWLINE);
return this;
}
/**
* Saves builder content to a file.
*
* @param outputDirPath Output directory
* @param filename Filename
* @throws IOException When error occurs while saving.
*/
public void toFile(final Path outputDirPath, final String filename) throws IOException {
final Path filePath = outputDirPath.resolve(filename);
Files.createDirectories(outputDirPath);
Files.createFile(filePath);
Files.write(filePath, toString().getBytes(UTF_8));
}
/**
* Converts builder content to a {@code String}.
* <p>
*
* @return Doc builder content
*/
@Override
public String toString() {
return builder.toString();
}
/**
* Normalizes a name such that it can be used as a unique
* <a href="https://en.wikipedia.org/wiki/Filename#Reserved_characters_and_words">filename</a>
* and/or anchor in AsciiDoc. Names are converted to lower-case, unsupported characters are collapsed to a single
* underscore-character, and parts are separated by an underscore.
*
* @param parts Name-parts to normalize
* @return Normalized name
*/
public static String normalizeName(final String... parts) {
if (isEmpty(parts)) {
throw new AsciiDocException("parts required");
}
String s = parts[0].toLowerCase(Locale.ROOT);
for (int i = 1; i < parts.length; ++i) {
s += NAMESPACE_DELIMITER + parts[i].toLowerCase(Locale.ROOT);
}
final String normalized;
final Matcher m = POSIX_FILENAME_REPLACEMENT_PATTERN.matcher(s);
if (m.find()) {
normalized = m.replaceAll(NAMESPACE_DELIMITER);
} else {
normalized = s;
}
final Matcher mm = SQUASH_UNDERSCORES_PATTERN.matcher(normalized);
return mm.find() ? mm.replaceAll(NAMESPACE_DELIMITER) : normalized;
}
}