diff --git a/dependency-export/src/main/java/net/woggioni/gradle/dependency/export/DependencyExportPlugin.java b/dependency-export/src/main/java/net/woggioni/gradle/dependency/export/DependencyExportPlugin.java index 076bebc..364cfcc 100644 --- a/dependency-export/src/main/java/net/woggioni/gradle/dependency/export/DependencyExportPlugin.java +++ b/dependency-export/src/main/java/net/woggioni/gradle/dependency/export/DependencyExportPlugin.java @@ -8,6 +8,9 @@ import org.gradle.api.plugins.ObjectConfigurationAction; import org.gradle.api.provider.Provider; public class DependencyExportPlugin implements Plugin { + + public static final String DEPENDENCY_EXPORT_GROUP = "dependency-export"; + @Override public void apply(Project project) { project.apply(new Action() { diff --git a/dependency-export/src/main/java/net/woggioni/gradle/dependency/export/ExportDependencies.java b/dependency-export/src/main/java/net/woggioni/gradle/dependency/export/ExportDependencies.java index 98810ea..c890cec 100644 --- a/dependency-export/src/main/java/net/woggioni/gradle/dependency/export/ExportDependencies.java +++ b/dependency-export/src/main/java/net/woggioni/gradle/dependency/export/ExportDependencies.java @@ -24,6 +24,7 @@ import org.gradle.api.plugins.JavaPlugin; import org.gradle.api.plugins.JavaPluginConvention; import org.gradle.api.provider.Property; import org.gradle.api.provider.Provider; +import org.gradle.api.tasks.Classpath; import org.gradle.api.tasks.Input; import org.gradle.api.tasks.InputFiles; import org.gradle.api.tasks.Internal; @@ -49,6 +50,8 @@ import java.util.stream.Collector; import java.util.stream.Collectors; import java.util.stream.Stream; +import static net.woggioni.gradle.dependency.export.DependencyExportPlugin.DEPENDENCY_EXPORT_GROUP; + public class ExportDependencies extends DefaultTask { @Getter(onMethod_ = { @Input }) @@ -74,6 +77,7 @@ public class ExportDependencies extends DefaultTask { private final JavaPluginConvention javaPluginConvention; @InputFiles + @Classpath public Provider getConfigurationFiles() { return configurationName.map(this::fetchConfiguration); } @@ -96,6 +100,7 @@ public class ExportDependencies extends DefaultTask { @Inject public ExportDependencies(ObjectFactory objects) { + setGroup(DEPENDENCY_EXPORT_GROUP); javaPluginConvention = getProject().getConvention().getPlugin(JavaPluginConvention.class); configurationName = objects.property(String.class).convention(JavaPlugin.RUNTIME_CLASSPATH_CONFIGURATION_NAME); Provider defaultOutputFileProvider = diff --git a/dependency-export/src/main/java/net/woggioni/gradle/dependency/export/RenderDependencies.java b/dependency-export/src/main/java/net/woggioni/gradle/dependency/export/RenderDependencies.java index dee30ab..12e4122 100644 --- a/dependency-export/src/main/java/net/woggioni/gradle/dependency/export/RenderDependencies.java +++ b/dependency-export/src/main/java/net/woggioni/gradle/dependency/export/RenderDependencies.java @@ -10,29 +10,37 @@ import org.gradle.api.model.ObjectFactory; import org.gradle.api.plugins.JavaPluginConvention; import org.gradle.api.provider.Property; import org.gradle.api.provider.Provider; +import org.gradle.api.tasks.CacheableTask; import org.gradle.api.tasks.Input; import org.gradle.api.tasks.InputFile; import org.gradle.api.tasks.Internal; import org.gradle.api.tasks.Optional; import org.gradle.api.tasks.OutputFile; +import org.gradle.api.tasks.PathSensitive; +import org.gradle.api.tasks.PathSensitivity; import org.gradle.api.tasks.TaskAction; import org.gradle.api.tasks.options.Option; import javax.inject.Inject; import java.io.File; import java.nio.file.Path; +import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.Objects; +import static net.woggioni.gradle.dependency.export.DependencyExportPlugin.DEPENDENCY_EXPORT_GROUP; + +@CacheableTask public class RenderDependencies extends DefaultTask { - @Getter(onMethod_ = { @InputFile }) + @Getter(onMethod_ = {@InputFile, @PathSensitive(PathSensitivity.NONE)}) private Provider sourceFile; - @Getter(onMethod_ = { @Input}) + @Getter(onMethod_ = {@Input}) private final Property format; - @Getter(onMethod_ = { @Input }) + @Getter(onMethod_ = {@Input}) private final Property graphvizExecutable; @Getter @@ -45,6 +53,7 @@ public class RenderDependencies extends DefaultTask { return outputFile.map(RegularFile::getAsFile).map(File::getAbsolutePath).getOrNull(); } + @Optional @OutputFile public Provider getResult() { return outputFile.map(RegularFile::getAsFile); @@ -64,33 +73,46 @@ public class RenderDependencies extends DefaultTask { } public void setExportTask(Provider taskProvider) { + dependsOn(taskProvider); sourceFile = taskProvider.flatMap(ExportDependencies::getResult); } @Inject public RenderDependencies(ObjectFactory objects) { + setGroup(DEPENDENCY_EXPORT_GROUP); sourceFile = objects.property(File.class); javaPluginConvention = getProject().getConvention().getPlugin(JavaPluginConvention.class); format = objects.property(String.class).convention("xlib"); graphvizExecutable = objects.property(String.class).convention("dot"); Provider defaultOutputFileProvider = getProject().provider(() -> new File(javaPluginConvention.getDocsDir(), "renderedDependencies")); - outputFile = objects.fileProperty().convention(getProject().getLayout().file(defaultOutputFileProvider)); + outputFile = objects.fileProperty().convention(getProject().getLayout().file(defaultOutputFileProvider) + .zip(format, (file, type) -> Objects.equals("xlib", type) ? null : file)); + getOutputs().upToDateWhen(t -> outputFile.isPresent()); } @TaskAction @SneakyThrows void run() { - Path destination = outputFile - .map(RegularFile::getAsFile) - .map(File::toPath) - .get(); - List cmd = Arrays.asList( + java.util.Optional destination = java.util.Optional.of( + outputFile + .map(RegularFile::getAsFile) + .map(File::toPath) + ) + .filter(Provider::isPresent) + .map(Provider::get); + + List cmd = new ArrayList<>(Arrays.asList( graphvizExecutable.get(), - "-T" + format.get(), - "-o" + destination, - sourceFile.get().toString() - ); + "-T" + format.get() + )); + + if (destination.isPresent()) { + cmd.add("-o"); + cmd.add(destination.get().toString()); + } + cmd.add(sourceFile.get().toString()); + int returnCode = new ProcessBuilder(cmd).inheritIO().start().waitFor(); if (returnCode != 0) { throw new GradleException("Error invoking graphviz"); diff --git a/graalvm/src/main/java/net/woggioni/gradle/graalvm/NativeImageTask.java b/graalvm/src/main/java/net/woggioni/gradle/graalvm/NativeImageTask.java index 97ba31a..5701c10 100644 --- a/graalvm/src/main/java/net/woggioni/gradle/graalvm/NativeImageTask.java +++ b/graalvm/src/main/java/net/woggioni/gradle/graalvm/NativeImageTask.java @@ -7,6 +7,7 @@ import org.gradle.api.file.FileCollection; import org.gradle.api.file.ProjectLayout; import org.gradle.api.file.RegularFileProperty; import org.gradle.api.logging.Logger; +import org.gradle.api.logging.Logging; import org.gradle.api.plugins.BasePluginExtension; import org.gradle.api.plugins.ExtensionContainer; import org.gradle.api.plugins.JavaApplication; @@ -48,6 +49,8 @@ public abstract class NativeImageTask extends Exec { public abstract Property getBuildStaticImage(); @Input public abstract Property getEnableFallback(); + @Input + public abstract Property getLinkAtBuildTime(); @Input public abstract Property getMainClass(); @@ -61,15 +64,17 @@ public abstract class NativeImageTask extends Exec { @OutputFile protected abstract RegularFileProperty getOutputFile(); - private final Logger logger; + + private static final Logger log = Logging.getLogger(NativeImageTask.class); + public NativeImageTask() { Project project = getProject(); - logger = project.getLogger(); setGroup(GRAALVM_TASK_GROUP); setDescription("Create a native image of the application using GraalVM"); getUseMusl().convention(false); getBuildStaticImage().convention(false); getEnableFallback().convention(false); + getLinkAtBuildTime().convention(false); ExtensionContainer ext = project.getExtensions(); JavaApplication javaApplication = ext.findByType(JavaApplication.class); if(!Objects.isNull(javaApplication)) { @@ -111,6 +116,9 @@ public abstract class NativeImageTask extends Exec { if(getUseMusl().get()) { result.add("--libc=musl"); } + if(getLinkAtBuildTime().get()) { + result.add("--link-at-build-time"); + } JavaModuleDetector javaModuleDetector = getJavaModuleDetector(); boolean useJpms = getMainModule().isPresent(); FileCollection classpath = getClasspath().get(); diff --git a/gradle.properties b/gradle.properties index 4dd763b..81b7d94 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,5 +1,5 @@ -lys.catalog.version=2025.01.31 -version.myGradlePlugins=2025.02.05 +lys.catalog.version=2025.02.05 +version.myGradlePlugins=2025.02.08 version.gradle=8.12 -gitea.maven.url = https://gitea.woggioni.net/api/packages/woggioni/maven \ No newline at end of file +gitea.maven.url = https://gitea.woggioni.net/api/packages/woggioni/maven diff --git a/jpms-check/src/main/groovy/net/woggioni/gradle/jpms/check/JPMSCheckPlugin.groovy b/jpms-check/src/main/groovy/net/woggioni/gradle/jpms/check/JPMSCheckPlugin.groovy index 88e2ee7..2b7b3fb 100644 --- a/jpms-check/src/main/groovy/net/woggioni/gradle/jpms/check/JPMSCheckPlugin.groovy +++ b/jpms-check/src/main/groovy/net/woggioni/gradle/jpms/check/JPMSCheckPlugin.groovy @@ -1,216 +1,43 @@ package net.woggioni.gradle.jpms.check -import groovy.json.JsonBuilder -import groovy.transform.Canonical + import groovy.transform.CompileStatic -import groovy.xml.MarkupBuilder -import org.gradle.api.GradleException import org.gradle.api.Plugin import org.gradle.api.Project -import org.gradle.api.artifacts.Configuration -import org.gradle.api.artifacts.result.ResolvedArtifactResult - -import java.nio.file.Files -import java.nio.file.Path -import java.nio.file.Paths -import java.util.jar.JarFile -import java.util.stream.Collectors -import java.util.stream.Stream -import java.util.zip.ZipFile +import org.gradle.api.file.RegularFile +import org.gradle.api.plugins.JavaPlugin +import org.gradle.api.plugins.ReportingBasePlugin +import org.gradle.api.reporting.ReportingExtension class JPMSCheckPlugin implements Plugin { - @Canonical - @CompileStatic - private class CheckResult { - ResolvedArtifactResult dep - String automaticModuleName - boolean multiReleaseJar - boolean moduleInfo - - boolean getJpmsFriendly() { - return automaticModuleName != null || moduleInfo - } - - @Override - boolean equals(Object other) { - if(other == null) { - return false - } else if(other.class == CheckResult.class) { - return dep?.id?.componentIdentifier == ((CheckResult) other).dep?.id?.componentIdentifier - } else { - return false - } - } - - @Override - int hashCode() { - return dep.id.componentIdentifier.hashCode() - } - } - - @CompileStatic - private Stream computeResults(Stream artifacts) { - return artifacts.filter { ResolvedArtifactResult res -> - res.file.exists() && res.file.name.endsWith(".jar") - }.map { resolvedArtifact -> - JarFile jarFile = new JarFile(resolvedArtifact.file).with { - if (it.isMultiRelease()) { - new JarFile( - resolvedArtifact.file, - false, - ZipFile.OPEN_READ, - Runtime.version() - ) - } else { - it - } - } - String automaticModuleName = jarFile.manifest?.with {it.mainAttributes.getValue("Automatic-Module-Name") } - def moduleInfoEntry = jarFile.getJarEntry("module-info.class") - new CheckResult( - resolvedArtifact, - automaticModuleName, - jarFile.isMultiRelease(), - moduleInfoEntry != null - ) - } - } - - private void createHtmlReport(Project project, Stream checkResults, Writer writer) { - def builder = new MarkupBuilder(writer) - int friendly = 0 - int total = 0 - def results = checkResults.peek { CheckResult res -> - total += 1 - if(res.jpmsFriendly) friendly += 1 - }.collect(Collectors.toList()) - builder.html { - head { - meta name: "viewport", content: "width=device-width, initial-scale=1" - InputStream resourceStream = getClass().classLoader.getResourceAsStream('net/woggioni/plugins/jpms/check/github-markdown.css') - resourceStream.withReader { Reader reader -> - style reader.text - } - body { - article(class: 'markdown-body') { - h1 "Project ${project.group}:${project.name}:${project.version}", style: "text-align: center;" - div { - table { - thead { - tr { - th "JPMS friendly" - th "Not JPMS friendly", colspan: 2 - th "Total", colspan: 2 - } - } - tbody { - tr { - td friendly, style: "text-align: center;" - td total - friendly, style: "text-align: center;", colspan: 2 - td total, style: "text-align: center;", colspan: 2 - } - } - thead { - th "Name" - th "Multi-release jar" - th "Automatic-Module-Name" - th "Module descriptor" - th "JPMS friendly" - } - tbody { - results.forEach {res -> - String color = res.jpmsFriendly ? "#dfd" : "fdd" - tr(style: "background-color:$color;") { - td res.dep.id.displayName - td style: "text-align: center;", res.multiReleaseJar ? "✓" : "✕" - td style: "text-align: center;", res.automaticModuleName ?: "n/a" - td style: "text-align: center;", res.moduleInfo ? "✓" : "✕" - td style: "text-align: center;", res.jpmsFriendly ? "✓" : "✕" - } - total += 1 - if(res.jpmsFriendly) friendly += 1 - } - } - } - } - } - } - } - } - } - - @CompileStatic - private createJsonReport(Stream checkResults, Writer writer) { - def builder = new JsonBuilder() - builder (checkResults.map { - [ - name: it.dep.id.componentIdentifier.displayName, - automaticModuleName: it.automaticModuleName, - isMultiReleaseJar: it.multiReleaseJar, - hasModuleInfo: it.moduleInfo, - jpmsFriendly: it.jpmsFriendly - ] - }.collect(Collectors.toList())) - builder.writeTo(writer) - } - @Override @CompileStatic void apply(Project project) { - project.tasks.register("jpms-check") {task -> - boolean recursive = project.properties["jpms-check.recursive"]?.with(Boolean.&parseBoolean) ?: false - String cfgName = project.properties["jpms-check.configurationName"] ?: "default" - String outputFormat = project.properties["jpms-check.outputFormat"] ?: "html" - Path outputFile = project.properties["jpms-check.outputFile"]?.with { - Paths.get(it as String) - } ?: with { + project.pluginManager.apply(ReportingBasePlugin.class) + project.tasks.register("jpms-check", JPMSCheckTask) {task -> + ReportingExtension reporting = project.extensions.getByType(ReportingExtension.class) + boolean recursive = project.properties["jpms-check.recursive"]?.with(Object.&toString)?.with(Boolean.&parseBoolean) ?: false + String cfgName = project.properties["jpms-check.configurationName"] ?: JavaPlugin.RUNTIME_CLASSPATH_CONFIGURATION_NAME + OutputFormat defaultOutputFormat = (project.properties["jpms-check.outputFormat"] + ?.with(Object.&toString) + ?.with(OutputFormat.&valueOf) + ?: OutputFormat.html) + task.getConfigurationName().convention(cfgName) + task.getRecursive().convention(recursive) + task.outputFormat.convention(defaultOutputFormat) + task.getOutputFile().convention(reporting.baseDirectory.zip(task.getOutputFormat(), { dir, outputFormat -> + RegularFile result = null switch(outputFormat) { - case "html": - Paths.get(project.buildDir.path, "jpms-report.html") + case OutputFormat.html: + result = dir.file( "jpms-report.html") break - case "json": - Paths.get(project.buildDir.path, "jpms-report.json") + case OutputFormat.json: + result = dir.file( "jpms-report.json") break - default: - throw new IllegalArgumentException("Unsupported output format: $outputFormat") } - } - task.doLast { - Stream projects = Stream.of(project) - if(recursive) { - projects = Stream.concat(projects, project.subprojects.stream()) - } - Set results = projects.flatMap { - Configuration requestedConfiguration = (project.configurations.find { Configuration cfg -> - cfg.canBeResolved && cfg.name == cfgName - } ?: { - def resolvableConfigurations = "[" + project.configurations - .grep { Configuration cfg -> cfg.canBeResolved } - .collect { "'${it.name}'" } - .join(",") + "]" - throw new GradleException("Configuration '$cfgName' doesn't exist or cannot be resolved, " + - "resolvable configurations in this project are " + resolvableConfigurations) - }) as Configuration - computeResults(requestedConfiguration.incoming.artifacts.artifacts.stream()) - }.collect(Collectors.toSet()) - Files.createDirectories(outputFile.parent) - Files.newBufferedWriter(outputFile).withWriter { - Stream resultStream = results.stream().sorted(Comparator.comparing { CheckResult res -> - res.dep.id.componentIdentifier.displayName - }) - switch(outputFormat) { - case "html": - createHtmlReport(project, resultStream, it) - break - case "json": - createJsonReport(resultStream, it) - break - default: - throw new IllegalArgumentException("Unsupported output format: $outputFormat") - } - } - } + result + })) } } } diff --git a/jpms-check/src/main/groovy/net/woggioni/gradle/jpms/check/JPMSCheckTask.groovy b/jpms-check/src/main/groovy/net/woggioni/gradle/jpms/check/JPMSCheckTask.groovy new file mode 100644 index 0000000..0aa7511 --- /dev/null +++ b/jpms-check/src/main/groovy/net/woggioni/gradle/jpms/check/JPMSCheckTask.groovy @@ -0,0 +1,213 @@ +package net.woggioni.gradle.jpms.check + +import groovy.json.JsonBuilder +import groovy.transform.Canonical +import groovy.transform.CompileStatic +import groovy.xml.MarkupBuilder +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.Project +import org.gradle.api.artifacts.Configuration +import org.gradle.api.artifacts.result.ResolvedArtifactResult +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.TaskAction + +import java.nio.file.Files +import java.nio.file.Path +import java.util.jar.JarFile +import java.util.stream.Collectors +import java.util.stream.Stream +import java.util.zip.ZipFile + +abstract class JPMSCheckTask extends DefaultTask { + + @Input + abstract Property getConfigurationName() + + @Input + abstract Property getRecursive() + + @Input + abstract Property getOutputFormat() + + @OutputFile + abstract RegularFileProperty getOutputFile() + + @Canonical + @CompileStatic + private class CheckResult { + ResolvedArtifactResult dep + String automaticModuleName + boolean multiReleaseJar + boolean moduleInfo + + boolean getJpmsFriendly() { + return automaticModuleName != null || moduleInfo + } + + @Override + boolean equals(Object other) { + if(other == null) { + return false + } else if(other.class == CheckResult.class) { + return dep?.id?.componentIdentifier == ((CheckResult) other).dep?.id?.componentIdentifier + } else { + return false + } + } + + @Override + int hashCode() { + return dep.id.componentIdentifier.hashCode() + } + } + + @CompileStatic + private Stream computeResults(Stream artifacts) { + return artifacts.filter { ResolvedArtifactResult res -> + res.file.exists() && res.file.name.endsWith(".jar") + }.map { resolvedArtifact -> + JarFile jarFile = new JarFile(resolvedArtifact.file).with { + if (it.isMultiRelease()) { + new JarFile( + resolvedArtifact.file, + false, + ZipFile.OPEN_READ, + Runtime.version() + ) + } else { + it + } + } + String automaticModuleName = jarFile.manifest?.with {it.mainAttributes.getValue("Automatic-Module-Name") } + def moduleInfoEntry = jarFile.getJarEntry("module-info.class") + new CheckResult( + resolvedArtifact, + automaticModuleName, + jarFile.isMultiRelease(), + moduleInfoEntry != null + ) + } + } + + private void createHtmlReport(Project project, Stream checkResults, Writer writer) { + def builder = new MarkupBuilder(writer) + int friendly = 0 + int total = 0 + def results = checkResults.peek { CheckResult res -> + total += 1 + if(res.jpmsFriendly) friendly += 1 + }.collect(Collectors.toList()) + builder.html { + head { + meta name: "viewport", content: "width=device-width, initial-scale=1" + InputStream resourceStream = getClass().classLoader.getResourceAsStream('net/woggioni/plugins/jpms/check/github-markdown.css') + resourceStream.withReader { Reader reader -> + style reader.text + } + body { + article(class: 'markdown-body') { + h1 "Project ${project.group}:${project.name}:${project.version}", style: "text-align: center;" + div { + table { + thead { + tr { + th "JPMS friendly" + th "Not JPMS friendly", colspan: 2 + th "Total", colspan: 2 + } + } + tbody { + tr { + td friendly, style: "text-align: center;" + td total - friendly, style: "text-align: center;", colspan: 2 + td total, style: "text-align: center;", colspan: 2 + } + } + thead { + th "Name" + th "Multi-release jar" + th "Automatic-Module-Name" + th "Module descriptor" + th "JPMS friendly" + } + tbody { + results.forEach {res -> + String color = res.jpmsFriendly ? "#dfd" : "fdd" + tr(style: "background-color:$color;") { + td res.dep.id.displayName + td style: "text-align: center;", res.multiReleaseJar ? "✓" : "✕" + td style: "text-align: center;", res.automaticModuleName ?: "n/a" + td style: "text-align: center;", res.moduleInfo ? "✓" : "✕" + td style: "text-align: center;", res.jpmsFriendly ? "✓" : "✕" + } + total += 1 + if(res.jpmsFriendly) friendly += 1 + } + } + } + } + } + } + } + } + } + + @CompileStatic + private createJsonReport(Stream checkResults, Writer writer) { + def builder = new JsonBuilder() + builder (checkResults.map { + [ + name: it.dep.id.componentIdentifier.displayName, + automaticModuleName: it.automaticModuleName, + isMultiReleaseJar: it.multiReleaseJar, + hasModuleInfo: it.moduleInfo, + jpmsFriendly: it.jpmsFriendly + ] + }.collect(Collectors.toList())) + builder.writeTo(writer) + } + + @TaskAction + @CompileStatic + def createReport() { + String cfgName = configurationName.get() + Path outputFile = outputFile.get().asFile.toPath() + Stream projects = Stream.of(project) + if(recursive.get()) { + projects = Stream.concat(projects, project.subprojects.stream()) + } + Set results = projects.flatMap { + Configuration requestedConfiguration = (project.configurations.find { Configuration cfg -> + cfg.canBeResolved && cfg.name == cfgName + } ?: { + def resolvableConfigurations = "[" + project.configurations + .grep { Configuration cfg -> cfg.canBeResolved } + .collect { "'${it.name}'" } + .join(",") + "]" + throw new GradleException("Configuration '$cfgName' doesn't exist or cannot be resolved, " + + "resolvable configurations in this project are " + resolvableConfigurations) + }) as Configuration + computeResults(requestedConfiguration.incoming.artifacts.artifacts.stream()) + }.collect(Collectors.toSet()) + Files.createDirectories(outputFile.parent) + Files.newBufferedWriter(outputFile).withWriter { + Stream resultStream = results.stream().sorted(Comparator.comparing { CheckResult res -> + res.dep.id.componentIdentifier.displayName + }) + switch(outputFormat.get()) { + case OutputFormat.html: + createHtmlReport(project, resultStream, it) + break + case OutputFormat.json: + createJsonReport(resultStream, it) + break + default: + throw new IllegalArgumentException("Unsupported output format: $outputFormat") + } + } + } +} diff --git a/jpms-check/src/main/groovy/net/woggioni/gradle/jpms/check/OutputFormat.groovy b/jpms-check/src/main/groovy/net/woggioni/gradle/jpms/check/OutputFormat.groovy new file mode 100644 index 0000000..e740330 --- /dev/null +++ b/jpms-check/src/main/groovy/net/woggioni/gradle/jpms/check/OutputFormat.groovy @@ -0,0 +1,5 @@ +package net.woggioni.gradle.jpms.check + +enum OutputFormat { + html, json +}