Implementing a new output formatter for Asciidoctor.js

Adding an internal output formatter

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

And per an example, this will show how to add the Reveal.js for Asciidoctor.js backend as an output formatter. As this also supports templates, this example illustrates adding template support to the DSL too.

Add the output formatter

Add the interface

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

AsciidoctorjsRevealjs.java
package org.asciidoctor.gradle.model5.js.formatters;

public interface AsciidoctorjsRevealjs extends AsciidoctorjsOutputFormatterVersioned, HasAsciidoctorjsTemplates { (1)
    RevealjsOptions getRevealjsOptions();
    default void revealjsOptions(Action<RevealjsOptions> configurator) {
        configurator.execute(getRevealjsOptions());
    }
    default void revealjsOptions(@DelegatesTo(RevealjsOptions.class) Closure<?> configurator) {
        ClosureUtils.configureItem(getRevealjsOptions(), configurator);
    }
}
1 Extend the AsciidoctorjsOutputFormatterVersioned interface so that build script authors can override the version of @asciidoctor/revealjs if they so wish. Also extend the HasAsciidoctorjsTemplates interface so that template support is available.

Add the NPM artifact to the version catalog

gradle/libs.versions.toml
asciidoctorjsRevealjs = "5.2.0"
By convention, we prefix NPM modules that are related to Asciidoctor.js with asciidoctorjs.

Update the version properties file

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

resources/META-INF/asciidoctor.gradle/asciidoctor5-js-core-plugin.properties
asciidoctorjs.revealjs=@@REVEALJS@@

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).

js-core/build.gradle
agProject {
    configurePlugin(
        'org.asciidoctor.js.revealjs', (1)
        'Asciidoctor.js Reveal.js Plugin', (2)
        'Provides Reveal.js formatter for all Asciidoctor.js toolchains', (3)
        'org.asciidoctor.gradle.model5.js.plugins.AsciidoctorjsRevealjsPlugin', (4)
        ['asciidoctorjs', 'revealjs', 'slides'] (5)
    )
    withVersionSubstitution("${project.name}.properties", [
        REVEALJS  : versionOf('asciidoctorjsRevealjs'), (6)
    ])
}
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 Asciidoctorjs and postfix with Plugin.
5 Add some useful tags.
6 Get the version from the version catalog.

Add the implementation

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

DefaultAsciidoctorjsRevealjs.groovy
package org.asciidoctor.gradle.model5.js.internal.formatters

import groovy.transform.CompileStatic

@CompileStatic
class DefaultAsciidoctorjsRevealjs extends AbstractAsciidoctorjsFormatterVersioned (1)
    implements AsciidoctorjsRevealjs { (2)

    public static final String DEFAULT_NAME = 'revealjs' (3)
    public static final String BACKEND_NAME = 'revealjs' (4)

    final boolean copyResources = true (5)
    final RevealjsOptions revealjsOptions (6)

    @Delegate
    private final DefaultAsciidoctorjsTemplates templates (7)

    @Inject
    DefaultAsciidoctorjsRevealjs(String name, AsciidoctorjsToolchain tc, Project project) {
        super(
            name,
            BACKEND_NAME, (8)
            'asciidoctor', (9)
            'reveal.js', (10)
            loadDefaultVersion('asciidoctorjs.revealjs', project, tc.class.classLoader), (11)
            tc,
            project
        )
        this.revealjsOptions = project.objects.newInstance(RevealjsOptions)
        attributes.putAll(revealjsOptions.attributeProvider)

        this.templates = project.objects.newInstance(DefaultAsciidoctorjsTemplates, tc, { String r -> (12)
            packageRequires.add(r)
        } as Consumer<String>)
    }

    @Override
    void configureTaskInputs(TaskInputs taskInputs) { (13)
        revealjsOptions.configureTaskInputs(taskInputs)
    }

    @Override
    protected Class<?> getDslType() {
        AsciidoctorjsRevealjs (14)
    }
}
1 Extend AbstractAsciidoctorjsFormatterVersioned which is the implementation of AsciidoctorjsOutputFormatterVersioned.
2 Also implement the interface that you defined previously - being AsciidoctorjsRevealjs in this example.
3 Define the default name for when the output formatter is registered by the org.asciidoctor.js plugin.
4 Define the Asciidoctor.js backend name. If it is the same as the default output formatter name, then assign the one to the other.
5 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 and Reveal.js backends, set it to true.
6 Since Reveal.js for Asciidoctor.js has many attributes that can be controlled, add this helper. This is optional for many output formatters, but for cases where ir is useful to configure attributes, adding some methods directly on the interface, or using a helper DSL block, is recommended.
7 If the backend supports templates, then it easiest is to simply add this delegate.
8 This needs to be the backend name.
9 Set the NPM scope, or null.
10 Set the NPM package name.
11 Set the key from the properties file, that you edited earlier.
12 If templates are supported and those templates might add additional NPM packages, ensure that those packages are added to the list of requires.
13 As the attributes of this output formatter will affect the final artifacts, ensure that changes in them will cause the appropriate AsciidoctorTask to run again.
14 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.js.internal.formatters and by convention is prefixed with Asciidoctorjs and postfixed with `Factory.

AsciidoctorjsRevealjsFactory.groovy
package org.asciidoctor.gradle.model5.js.internal.formatters

import groovy.transform.CompileStatic

@CompileStatic
class AsciidoctorjsRevealjsFactory extends AbstractFactory
    implements NamedDomainObjectFactory<AsciidoctorjsRevealjs> { (1)

    @Inject
    AsciidoctorjsRevealjsFactory(AsciidoctorjsToolchain toolchain, Project project) {
        super(toolchain, project)
    }

    @Override
    AsciidoctorjsRevealjs create(String name) { (2)
        objectFactory.newInstance(DefaultAsciidoctorjsRevealjs, 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.js.plugins. By convention, this class is prefixed with Asciidoctorjs and postfixed with Plugin.

AsciidoctorjsRevealjsPlugin.groovy
package org.asciidoctor.gradle.model5.js.plugins

import groovy.transform.CompileStatic
import org.asciidoctor.gradle.model5.core.AsciidoctorModelExtension
import org.asciidoctor.gradle.model5.js.formatters.AsciidoctorjsRevealjs
import org.asciidoctor.gradle.model5.js.internal.formatters.AsciidoctorjsRevealjsFactory
import org.asciidoctor.gradle.model5.js.internal.formatters.DefaultAsciidoctorjsRevealjs
import org.gradle.api.Plugin
import org.gradle.api.Project

import static org.asciidoctor.gradle.model5.js.JsModel.registerOutputFormatterFactory
import static org.asciidoctor.gradle.model5.js.JsModel.registerOutputFormatterOnAllToolchains

@CompileStatic
class AsciidoctorjsRevealjsPlugin implements Plugin<Project> {

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

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

        registerOutputFormatterFactory(
            toolchains,
            AsciidoctorjsRevealjs, (1)
            AsciidoctorjsRevealjsFactory, (2)
            project.objects
        )

        project.pluginManager.withPlugin(AsciidoctorjsPlugin.PLUGIN_ID) {
            registerOutputFormatterOnAllToolchains(
                toolchains,
                AsciidoctorjsRevealjs, (3)
                DefaultAsciidoctorjsRevealjs.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 js-core/src/integrationTest/groovy directory and add an integration test in the org.asciidoctor.gradle.model5.js.formatters package.

AsciidoctorjsRevealjsSpec
package org.asciidoctor.gradle.model5.js.formatters

import org.asciidoctor.gradle.model5.js.internal.formatters.DefaultAsciidoctorjsRevealjs
import org.asciidoctor.gradle.testfixtures.model5.IntegrationSpecification
import spock.lang.PendingFeatureIf

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

class AsciidoctorjsRevealjsSpec extends IntegrationSpecification {

    @PendingFeatureIf(reason = 'Not yet supported on Windows', value = { IS_WINDOWS })
    void 'Reveal.js formatter will convert files and copy resources'() {
        setup:
        final taskName = 'asciidoctorRevealjs' (1)
        final outputDir = new File(buildDir, 'docs/asciidoc/revealjs') (2)

        writeBuildFile()
        copyTestProject('resources')

        configureSourceSetGroovy(DEFAULT_PUBLICATION, """
        resources {
            include 'images/**'
        }
        """.stripIndent())

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

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

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

    void writeBuildFile() {
        writeBasicBuildFileGroovy(['org.asciidoctor.js.revealjs']) (5)
        addOutputToSourceSetGroovy(DEFAULT_TOOLCHAIN, DefaultAsciidoctorjsRevealjs.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 Asciidoctor.js engine during the conversion process.

This example already used RevealjsOptions as how this could be done for Reveal.js for Asciidoctor.js. If you browse through other formatters in org.asciidoctor.gradle.model5.js.formatters and org.asciidoctor.gradle.model5.js.internal.formatters you will find more examples.

Documentation

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

  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.