Implementing a new output formatter for AsciidoctorJ

Adding an internal output formatter

This section describes the minimum requirements of adding a new output formatter to the model5/jvm-core subproject.

And by example this will show how to add the EPUB backend as an output formatter.

Add the output formatter

Add the interface

Firstly, add the interface to org.asciidoctor.gradle.model5.jvm.formatters. By convention, the name is prefixed with Asciidoctorj.

AsciidoctorjEpub.java
package org.asciidoctor.gradle.model5.jvm.formatters;

public interface AsciidoctorjEpub extends AsciidoctorjOutputFormatterVersioned { (1)
}
1 Extend the AsciidoctorjOutputFormatterVersioned interface so that build script authors can override the version of asciidoctorj-epub if they so wish.

Add the Maven coordinate to code

Go to the org.asciidoctor.gradle.model5.jvm.JvmModel class and add a constant for the Maven module coordinate.

JvmModel
package org.asciidoctor.gradle.model5.jvm

@CompileStatic
class JvmModel {
    public static final String ASCIIDOCTORJ_EPUB_DEPENDENCY = "${ASCIIDOCTORJ_GROUP}:asciidoctorj-epub3"
}

Add the artifact to the version catalog

gradle/libs.versions.toml
[versions]
asciidoctorjEpub = "2.2.0"
[libraries]
asciidoctorjEpub = { module = "org.asciidoctor:asciidoctorj-epub3", version.ref = "asciidoctorjEpub" }

Update the version properties file

Edit the asciidoctor5-jvm-core-plugin.properties file and add a version token.

resources/META-INF/asciidoctor.gradle/asciidoctor5-jvm-core-plugin.properties
asciidoctorj.epub=@@EPUB@@

Update the build script

Go to the subproject’s build.gradle and add the substitutions and plugin details. (The plugin itself will be implemented further down in these steps).

jvm-core/build.gradle
agProject {
    configurePlugin(
        'org.asciidoctor.jvm.epub', (1)
        'AsciidoctorJ EPUB Plugin', (2)
        'Provides EPUB formatters for all AsciidoctorJ toolchains', (3)
        'org.asciidoctor.gradle.model5.jvm.plugins.AsciidoctorjEpubPlugin', (4)
        ['asciidoctorj', 'epub', 'epub3'] (5)
    )
    withVersionSubstitution("${project.name}.properties", [
        EPUB     : versionOf('asciidoctorjEpub'), (6)
    ])
}
dependencies {
    cachingOnly(libs.asciidoctorjEpub) (7)
}
1 The identifier of the plugin.
2 A name for the plugin that will provide this output formatter.
3 A description of the plugin.
4 The actual class that will implement that plugin. Note that the convention is to prefix with Asciidoctorj and postfix with Plugin.
5 Add some useful tags.
6 Get the version from the version catalog.
7 Ensure that the artifact is cached for offline testing.

Add the implementation

Now add the implementation which by convention is prefixed with DefaultAsciidoctorj and placed in org.asciidoctor.gradle.model5.jvm.internal.formatters.

DefaultAsciidoctorjEpub.groovy
package org.asciidoctor.gradle.model5.jvm.internal.formatters

import groovy.transform.CompileStatic
import org.asciidoctor.gradle.model5.core.errors.IncorrectOutputFormatException
import org.asciidoctor.gradle.model5.jvm.JvmModel
import org.asciidoctor.gradle.model5.jvm.formatters.AsciidoctorjEpub
import org.asciidoctor.gradle.model5.jvm.internal.PluginUtils

@CompileStatic
class DefaultAsciidoctorjEpub extends AbstractAsciidoctorjFormatterVersioned implements AsciidoctorjEpub { (1)
    public static final String DEFAULT_NAME = 'epub' (2)
    public static final String BACKEND_NAME = 'epub3' (3)

    final boolean copyResources = false (4)

    final Property<Integer> level (5)
    final DirectoryProperty stylesDir (6)
    final DirectoryProperty frontmatterDir (7)

    @Inject
    @SuppressWarnings('UnnecessaryCast')
    DefaultAsciidoctorjEpub(String name, AsciidoctorjToolchain tc, Project project) {
        super(
            name,
            BACKEND_NAME, (8)
            JvmModel.ASCIIDOCTORJ_EPUB_DEPENDENCY, (9)
            PluginUtils.loadDefaultVersion('asciidoctorj.epub', project, tc.class.classLoader), (10)
            tc,
            project
        )
    }
}
1 Extend AbstractAsciidoctorjFormatterVersioned which is the implementation of AsciidoctorjOutputFormatterVersioned. Also implement the interface that you defined previously - being AsciidoctorjEpub in this example.
2 Define the default name for when the output formatter is registered by the org.asciidoctor.jvm plugin.
3 Define the AsciidoctorJ backend name. If it is the same as the default output formatter name, then just assign the one to the other.
4 If the backend does not require additional resources to be copied to the output directory, then set this to false. Otherwise, in cases like HTML backends, set it to true.
5 This needs to be the backend name.
6 Set the name of the Maven coordinate, that was defined above.
7 Set the type that the showAsciidoctorToolchains task must display. This is also the type that build script users will use to perform configuration. Normally it is just the type of the interface.

Add a factory

The factory class is so that instances of the output formatter can be created and placed in the container of registered output formatters. This factory is also places inside org.asciidoctor.gradle.model5.jvm.internal.formatters and by convention is prefixed with Asciidoctorj and postfixed with `Factory.

AsciidoctorjEpubFactory.groovy
package org.asciidoctor.gradle.model5.jvm.internal.formatters

@CompileStatic
class AsciidoctorjEpubFactory implements NamedDomainObjectFactory<AsciidoctorjEpub> { (1)

    private final ObjectFactory objectFactory
    private final AsciidoctorjToolchain toolchain

    @Inject
    AsciidoctorjEpubFactory(AsciidoctorjToolchain toolchain, Project project) {
        this.objectFactory = project.objects
        this.toolchain = toolchain
    }

    @Override
    AsciidoctorjEpub create(String name) { (2)
        objectFactory.newInstance(DefaultAsciidoctorjEpub, name, toolchain) (3)
    }
}
1 Reference the interface name
2 The return type of the create method is the interface name.
3 Pass the implementation class as that is what we really need to instantiate.

Add a plugin

A plugin needs to be added to org.asciidoctor.gradle.model5.jvm.plugins. By convention, this class is prefixed with Asciidoctorj and postfixed with Plugin.

AsciidoctorJEpubPlugin.groovy
package org.asciidoctor.gradle.model5.jvm.plugins

import groovy.transform.CompileStatic

import org.asciidoctor.gradle.model5.core.AsciidoctorModelExtension
import org.asciidoctor.gradle.model5.jvm.formatters.AsciidoctorjEpub
import org.asciidoctor.gradle.model5.jvm.internal.formatters.AsciidoctorjEpubFactory
import org.asciidoctor.gradle.model5.jvm.internal.formatters.DefaultAsciidoctorjEpub
import org.gradle.api.Plugin
import org.gradle.api.Project

import static org.asciidoctor.gradle.model5.jvm.JvmModel.registerOutputFormatterFactory
import static org.asciidoctor.gradle.model5.jvm.JvmModel.registerOutputFormatterOnAllToolchains

@CompileStatic
class AsciidoctorjEpubPlugin implements Plugin<Project> {

    @Override
    void apply(Project project) {
        project.pluginManager.tap {
            apply(AsciidoctorjPlugin)
        }

        final asciidoc = project.extensions.getByType(AsciidoctorModelExtension)
        final toolchains = asciidoc.toolchains

        registerOutputFormatterFactory(
                toolchains,
                AsciidoctorjEpub, (1)
                AsciidoctorjEpubFactory, (2)
                project.objects
        )

        project.pluginManager.withPlugin(AsciidoctorjPlugin.PLUGIN_ID) {
            registerOutputFormatterOnAllToolchains(
                    toolchains,
                    AsciidoctorjEpub, (3)
                    DefaultAsciidoctorjEpub.DEFAULT_NAME (4)
            )
        }
    }
}
1 Reference the interface of the output formatter
2 Reference the factory of the output formatter
3 Once again, reference the interface of the output formatter
4 Provide the name of the default output formatter that will be registered on the toolchains.

Add an integration test

Go to the jvm-core/src/integrationTest/groovy directory and add an integration test in the org.asciidoctor.gradle.model5.jvm.formatters package.

AsciidoctorjEpubSpec
package org.asciidoctor.gradle.model5.jvm.formatters

import org.asciidoctor.gradle.model5.jvm.internal.formatters.DefaultAsciidoctorjEpub
import org.asciidoctor.gradle.testfixtures.model5.IntegrationSpecification

import static org.asciidoctor.gradle.model5.core.internal.publications.PublicationUtils.DEFAULT_PUBLICATION
import static org.asciidoctor.gradle.model5.jvm.plugins.AsciidoctorjPlugin.DEFAULT_TOOLCHAIN
import static org.gradle.testkit.runner.TaskOutcome.SUCCESS

class AsciidoctorjEpubSpec extends IntegrationSpecification {

    void 'Epub formatter will convert files and not copy resources'() {
        setup:
        final taskName = 'asciidoctorEpub' (1)
        final outputDir = new File(buildDir, 'docs/asciidoc/epub') (2)

        writeBuildFile()
        copyTestProject('resources')

        when:
        final result = getGradleRunnerConfigCache(IS_GROOVY_DSL, [taskName, '-s', '-i']).build()

        then: 'Task completed successfully'
        result.task(":${taskName}").outcome == SUCCESS

        and: 'Content exists'
        fileExists(outputDir, 'simple.epub') (3)
        !fileExists(outputDir, 'images/fake11.txt') (4)
    }

    void writeBuildFile() {
        writeBasicBuildFileGroovy(['org.asciidoctor.jvm.epub']) (5)
        addOutputToSourceSetGroovy(DEFAULT_TOOLCHAIN, DefaultAsciidoctorjEpub.DEFAULT_NAME, DEFAULT_PUBLICATION) (6)
    }
}
1 Set the name of the task.
2 Set the output directory.
3 Check that the document was created.
4 Check that resources were not copied if you set copyResources = false in the implementation class. If you did the opposite, then invert the logic of this test.
5 Set the correct plugin name.
6 This configures the build script to create an output. Set the second parameter to the correct value.

You can extend this class with further tests as you expand the output formatter with additional functionality.

Configure specific attributes

An output formatter might require attributes that are specific to that output formatter. You can add additional configuration to the class and then configure a provider to send those to the AsciidoctorJ engine during the conversion process.

To further this example the epub-chapter-level, epub3-frontmatterdir and epub3-stylesdir will be made configurable from the DSL. Return to the interface first and add methods for these.

AsciidoctorjEpub.java
public interface AsciidoctorjEpub extends AsciidoctorjOutputFormatterVersioned { (1)

    void setChapterLevel(int level);
    void setFrontmatterDir(Object dir);
    void setStylesDir(Object dir);
}
1 Add methods to the interface.

Now go to the implementation class and configure these methods.

DefaultAsciidoctorjEpub.groovy
final Property<Integer> level (1)
final DirectoryProperty stylesDir (2)
final DirectoryProperty frontmatterDir (3)
1 Store the optional level as a property.
2 Store the optional style directory as a directory property.
3 Store the optional frontmatter directory as a directory property.

Initialise these values in the injected constructor.

DefaultAsciidoctorjEpub.groovy
this.level = project.objects.property(Integer)
this.stylesDir = project.objects.directoryProperty()
this.frontmatterDir = project.objects.directoryProperty()

attributes.putAll(this.level.map {
    ['epub-chapter-level': it] as Map<String, Object>
}.orElse(EMPTY_MAP)) (1)

attributes.putAll(this.stylesDir.map {
    ['epub3-stylesdir': it.asFile.absolutePath] as Map<String, Object>
}.orElse(EMPTY_MAP)) (2)

attributes.putAll(this.frontmatterDir.map {
    ['epub3-frontmatterdir': it.asFile.absolutePath] as Map<String, Object>
}.orElse(EMPTY_MAP)) (3)
1 Add the chapter level to the attributes only if the property was set.
2 Set the styles directory to the attributes only if the property was set.
3 Set the frontmatter directory to the attributes only if the property was set.

Finally, allow the properties to be configured.

DefaultAsciidoctorjEpub.groovy
void setFrontmatterDir(Object dir) {
    ccso.fsOperations().updateDirectoryProperty(this.frontmatterDir, dir) (1)
}
void setStylesDir(Object dir) {
    ccso.fsOperations().updateDirectoryProperty(this.stylesDir, dir)
}
@Override
void setChapterLevel(int level) {
    if (level < 1 || level > 5) {
        throw new IncorrectOutputFormatException('The level can only be set between 1-5 (inclusive)')
    }
    this.level.set(level)
}
@Override
void configureTaskInputs(TaskInputs taskInputs) { (2)
    taskInputs.property('chapter-level', this.level).optional(true)
    taskInputs.dir(this.stylesDir).optional(true).withPathSensitivity(PathSensitivity.RELATIVE)
    taskInputs.dir(this.frontmatterDir).optional(true).withPathSensitivity(PathSensitivity.RELATIVE)
}
1 ccso.fsOperations is available from the base class. updateDirectoryProperty is an invaluable utility to set a DirectoryProperty from any object that is convertible to a file.
2 When changes in these settings could change the structure and content, it is worth making them task inputs.

Documentation

  1. Document the formatter by placing a new document in docs/modules/ROOT/pages/model5/asciidoctorj.

  2. Update docs/modules/ROOT/nav.adoc to link to your document.

  3. Update docs/modules/ROOT/pages/bootstrap.adoc to add information about the plugin.