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