Extension Migration: 1.5.x to 1.6.x

AsciidoctorJ 1.6.0 fixed many issues with extensions, but also brought some incompatible changes with 1.5.x. In fact these incompatible changes were necessary to fix some of these bugs.

This guide explains how to migrate an existing extension for AsciidoctorJ 1.5.x to 1.6.0. We will take an existing extension from the 1.5.x test cases and migrate it step by step to 1.5.x.

If you want to migrate from 1.5.x to the latest version, i.e. 2.0.x, please follow all individual sections, i.e. first Extension Migration Guide: 1.5.x to 1.6.x and then this.

The original extension

As an example we are taking the YellStaticBlock extension. This is a BlockProcessor that transforms the contents of the corresponding block to upper case.

YellStaticBlock.java for AsciidoctorJ 1.5.x
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import java.util.stream.Collectors;

import org.asciidoctor.ast.AbstractBlock;
import org.asciidoctor.extension.BlockProcessor;
import org.asciidoctor.extension.Reader;

import static java.util.stream.Collectors.joining;

public class YellStaticBlock extends BlockProcessor {

    private static Map<String, Object> configs = new HashMap<String, Object>() {{
        put("contexts", Arrays.asList(":paragraph"));
        put("content_model", ":simple");
    }};

    public YellStaticBlock(String name, Map<String, Object> config) {
        super(name, configs);
    }

    @Override
    public Object process(AbstractBlock parent, Reader reader, Map<String, Object> attributes) {

        String upperLines = reader.readLines().stream()   (1)
            .map(String::toUpperCase)
            .collect(joining("\n"));

        return createBlock(                               (2)
            parent,
            "paragraph",
            upperLines,
            attributes,
            new HashMap<>());
    }
}
1 Transform content to uppercase
2 Create new block that replaces the processed one.

When you simply update your dependency on AsciidoctorJ from 1.5.x to 1.6.0 the compiler will complain with an error similar to this:

.../YellStaticBlock.java:8: error: cannot find symbol
import org.asciidoctor.ast.AbstractBlock;
                          ^
  symbol:   class AbstractBlock
  location: package org.asciidoctor.ast
.../YellStaticBlock.java:24: error: cannot find symbol
    public Object process(AbstractBlock parent, Reader reader, Map<String, Object> attributes) {
                          ^
  symbol:   class AbstractBlock
  location: class YellStaticBlock
2 errors

This is because the AST interfaces were renamed to better represent their purpose. The next section shows how these have to be updated.

Update to new AST class names

The following table shows the new AST class names with their counterparts in AsciidoctorJ 1.5.x. See Understanding the AST Classes for details about the purpose of the classes.

Class names of AST nodes in AsciidoctorJ 1.6.0 and 1.5.x
Name in 1.6.0 Name in 1.5.x

Document

DocumentRuby

ContentNode

AbstractNode

StructuralNode

AbstractBlock

Block

Block

Section

Section

List

List

ListItem

ListItem

DescriptionList

N/A

DescriptionListEntry

N/A

Table

Table

Column

Column

Row

Row

Cell

Cell

PhraseNode

Inline

As you can see not all AST classes were renamed, but in particular those classes that appear in the signatures of the processor classes were renamed.

After renaming the classes the new Processor looks like this:

YellStaticBlock.java after renaming the AST classes
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import org.asciidoctor.ast.StructuralNode;
import org.asciidoctor.extension.BlockProcessor;
import org.asciidoctor.extension.Reader;

public class YellStaticBlock extends BlockProcessor {

    private static Map<String, Object> configs = new HashMap<String, Object>() {{
        put("contexts", Arrays.asList(":paragraph"));
        put("content_model", ":simple");
    }};

    public YellStaticBlock(String name, Map<String, Object> config) {
        super(name, configs);
    }

    @Override
    public Object process(StructuralNode parent, Reader reader, Map<String, Object> attributes) {
        List<String> lines = reader.readLines();
        String upperLines = null;
        for (String line : lines) {
            if (upperLines == null) {
                upperLines = line.toUpperCase();
            }
            else {
                upperLines = upperLines + "\n" + line.toUpperCase();
            }
        }

        return createBlock(parent,
            "paragraph",
            Arrays.asList(upperLines),
            attributes,
            new HashMap<Object, Object>());
    }
}

Together with the AST class names also the factory methods of the common interface of all extensions, org.asciidoctor.extension.Processor were renamed. While this isn’t a problem here, for example invocations of createInline() have to be renamed to createPhraseNode() according to the table above.

This extension will already run with AsciidoctorJ 1.6.0 and the following test will pass:

Asciidoctor asciidoctor = Asciidoctor.Factory.create();
asciidoctor.javaExtensionRegistry().block("yell", YellStaticBlock.class);

final String doc = "[yell]\nHello World";

final String result = asciidoctor.convert(doc, Options.builder().build());
Document htmlDoc = Jsoup.parse(result);
assertEquals("HELLO WORLD", htmlDoc.select("p").first().text());

There are some additional steps you can take to make this extension more concise.

The extension explicitly creates a map for its configuration, stores the values in it and passes it to the base class via the constructor. This configuration is static and never changes. Also the block name is passed when registering the extension which also might never change.

Finally it is rather ugly that the constructor has to take a parameter config, that it completely ignores.

The next section shows how this can be done in a more concise way.

Instantiating and configuring extensions

The configuration of an extension has to be known at the time of registration. With AsciidoctorJ 1.5.x the way to define the configuration was to pass it to the super constructor and every extension type had to implement one certain constructor. For many extension type a block or macro name also has to be passed to the registration method.

This configuration is static most of the times and often extensions are registered as classes instead of instances:

asciidoctor.javaExtensionRegistry().block("yell", YellStaticBlock.class);
// instead of
asciidoctor.javaExtensionRegistry().block("yell", new YellStaticBlock(...));

When you register an extension as a class, AsciidoctorJ 1.6.0 allows to remove most of the boilerplate code to create the configuration by using Java annotations. Also block or macro names can be configured with annotations directly at the extension implementation itself.

This way the extension can become this:

YellStaticBlock.java for AsciidoctorJ 1.6.0
import org.asciidoctor.ast.ContentModel;
import org.asciidoctor.ast.StructuralNode;
import org.asciidoctor.extension.BlockProcessor;
import org.asciidoctor.extension.Contexts;
import org.asciidoctor.extension.Name;
import org.asciidoctor.extension.Reader;

import java.util.HashMap;
import java.util.Map;

import static java.util.stream.Collectors.joining;

@Contexts(Contexts.PARAGRAPH)
@ContentModel(ContentModel.COMPOUND)
@Name("yell")
public class YellStaticBlock extends BlockProcessor {

    @Override
    public Object process(StructuralNode parent, Reader reader, Map<String, Object> attributes) {

        String upperLines = reader.readLines().stream()
            .map(String::toUpperCase)
            .collect(joining("\n"));

        return createBlock(parent, "paragraph", upperLines, attributes, new HashMap<Object, Object>());
    }
}

Now the test case can be further simplified to this:

Asciidoctor asciidoctor = Asciidoctor.Factory.create();
asciidoctor.javaExtensionRegistry().block(YellStaticBlock.class);  (1)

final String doc = "[yell]\nHello World";

final String result = asciidoctor.convert(doc, Options.builder().build());
Document htmlDoc = Jsoup.parse(result);
assertEquals("HELLO WORLD", htmlDoc.select("p").first().text());
1 Passing the block name was removed and is taken from the annotation of the extension. If you explicitly want a different block name, e.g. loud, it is still possible to pass it by calling JavaExtensionRegistry.block("loud", YellStaticBlock.class).

And this was already it. The extension is now compatible to AsciidoctorJ 1.6.0.

For further examples you might want to compare the following examples:

Name

Extension Type

YellBlock

BlockProcessor

1.5.x

1.6.0

ArrowsAndBoxesBlock

BlockProcessor

1.5.x

1.6.0

ManpageMacro

InlineMacro

1.5.x

1.6.0