Repository: asciidoctor/asciidoctor-maven-plugin
Pull request #769 (2024-02-05 23:31:10)

(v2.2.x) #763 missing breadcrumb in maven site module
https://github.com/asciidoctor/asciidoctor-maven-plugin/issues/763

Extract document title, author(s) and conversion date from Asciidoctor to enrich maven site head and genereta breadcrumbs.
At the end, used `Asciidoctor.load` because it offers a unique way to obtain the data without manual parsing.
Concerns about performaces are mitigated using `parse_header_only` (`parseHeaderOnly` in AsciidoctorJ API).


Patch:
diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml
index d7475f1..3a4eeb0 100644
--- a/.github/workflows/build.yaml
+++ b/.github/workflows/build.yaml
@@ -122,8 +122,7 @@ jobs:
       - name: Collect artifacts
         run: |
           mkdir -p $ARTIFACTS_DIR
-          cp -r $HOME/.m2/repository/org/asciidoctor/asciidoctor-maven-* $ARTIFACTS_DIR
-          cp -r $HOME/.m2/repository/org/asciidoctor/*-doxia-module $ARTIFACTS_DIR
+          cp -r $HOME/.m2/repository/org/asciidoctor/asciidoctor-maven-plugin/ $ARTIFACTS_DIR
       - name: Verify JAR signatures
         run: find $ARTIFACTS_DIR -type f -name "*.jar" -exec gpg --verify "{}.asc" \;
       - name: Upload artifacts
diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc
index a71202e..4998545 100644
--- a/CHANGELOG.adoc
+++ b/CHANGELOG.adoc
@@ -13,6 +13,14 @@ For a detailed view of what has changed, refer to the {uri-repo}/commits/main[co
 
 == Unreleased
 
+Improvements::
+
+  * Push Asiidoctor header metadata (author(s), conversion time and title) to maven-site pages (#769)
+
+Bug Fixes::
+
+  * Fix breadcrumbs not showing the document title in maven-site pages (#763)
+
 == v2.2.5 (2024-01-11)
 
 Bug Fixes::
diff --git a/docs/modules/site-integration/nav.adoc b/docs/modules/site-integration/nav.adoc
index 15c4883..71ff6b1 100644
--- a/docs/modules/site-integration/nav.adoc
+++ b/docs/modules/site-integration/nav.adoc
@@ -1,4 +1,5 @@
 * Maven Site Integration
 ** xref:introduction.adoc[]
 ** xref:setup-and-configuration.adoc[]
+** xref:exposed-metadata.adoc[]
 ** xref:compatibility-matrix.adoc[]
diff --git a/docs/modules/site-integration/pages/exposed-metadata.adoc b/docs/modules/site-integration/pages/exposed-metadata.adoc
new file mode 100644
index 0000000..d9d6e20
--- /dev/null
+++ b/docs/modules/site-integration/pages/exposed-metadata.adoc
@@ -0,0 +1,17 @@
+= Exposed metadata
+:asciidoctor-docs-url: https://docs.asciidoctor.org/asciidoc/latest
+:maven-site-plugin-docs-url: https://maven.apache.org/plugins/maven-site-plugin
+
+The Asciidoctor Maven Site integration collaborates with Doxia to expose some of its information.
+
+== Document Header metadata
+
+The following elements from the {asciidoctor-docs-url}/document/header/[header] are integrated:
+
+document title:: used to inform the {maven-site-plugin-docs-url}/examples/sitedescriptor.html#Breadcrumbs[breadcrumb] line when these are enabled.
+
+author(s):: full representation (full name and email) will be present as HTML `<meta name="author" ... >` tags inside the HTML `<head>`.
+In case of multiple authors, each one will appear in a distinct `meta` element.
+
+revision date:: the header revision date value will be presented as-is in a `<meta name="date" ... >` element.
+Alternatively, if not set, the generated value of `docdatetime` will be used.
diff --git a/pom.xml b/pom.xml
index 1b2e04f..6dc49e0 100644
--- a/pom.xml
+++ b/pom.xml
@@ -92,7 +92,7 @@
             <dependency>
                 <groupId>org.junit</groupId>
                 <artifactId>junit-bom</artifactId>
-                <version>5.10.1</version>
+                <version>5.10.2</version>
                 <type>pom</type>
                 <scope>import</scope>
             </dependency>
@@ -178,7 +178,7 @@
         <dependency>
             <groupId>org.assertj</groupId>
             <artifactId>assertj-core</artifactId>
-            <version>3.25.2</version>
+            <version>3.25.3</version>
             <scope>test</scope>
         </dependency>
         <dependency>
diff --git a/src/it/maven-site-plugin/pom.xml b/src/it/maven-site-plugin/pom.xml
index 5521c40..14660fb 100644
--- a/src/it/maven-site-plugin/pom.xml
+++ b/src/it/maven-site-plugin/pom.xml
@@ -22,7 +22,7 @@
             <plugin>
                 <groupId>org.apache.maven.plugins</groupId>
                 <artifactId>maven-project-info-reports-plugin</artifactId>
-                <version>3.1.2</version>
+                <version>3.4.5</version>
             </plugin>
             <!-- tag::plugin-decl[] -->
             <plugin>
diff --git a/src/it/maven-site-plugin/src/site/asciidoc/file-with-toc.adoc b/src/it/maven-site-plugin/src/site/asciidoc/file-with-toc.adoc
index 061f8b7..9b0f2bd 100644
--- a/src/it/maven-site-plugin/src/site/asciidoc/file-with-toc.adoc
+++ b/src/it/maven-site-plugin/src/site/asciidoc/file-with-toc.adoc
@@ -1,4 +1,6 @@
 = File with TOC
+The Author
+:docdatetime: 2024-02-07 23:36:29
 :toc:
 
 [.lead]
diff --git a/src/it/maven-site-plugin/src/site/site.xml b/src/it/maven-site-plugin/src/site/site.xml
index 31e6381..199025c 100644
--- a/src/it/maven-site-plugin/src/site/site.xml
+++ b/src/it/maven-site-plugin/src/site/site.xml
@@ -1,5 +1,9 @@
 <project name="Maven Site Plugin IT">
     <body>
+        <breadcrumbs>
+            <item name="Doxia" href="https://maven.apache.org/doxia/index.html"/>
+            <item name="Trunk" href="https://maven.apache.org/doxia/doxia/index.html"/>
+        </breadcrumbs>
         <menu name="AsciiDoc Pages">
             <item name="File with TOC" href="/file-with-toc.html"/>
         </menu>
diff --git a/src/it/maven-site-plugin/validate.bsh b/src/it/maven-site-plugin/validate.bsh
deleted file mode 100644
index 3cc2ca8..0000000
--- a/src/it/maven-site-plugin/validate.bsh
+++ /dev/null
@@ -1,111 +0,0 @@
-import java.io.*;
-import java.nio.charset.StandardCharsets;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
-
-File outputDir = new File(basedir, "target/site");
-
-String[] expectedFiles = {
-	"file-with-toc.html"
-};
-
-String[] unexpectedFiles = {
-    "_include.html"
-};
-
-Pattern tocEntry = Pattern.compile("<li><a href=\"#(.*?)\">.*");
-Pattern elementIdPattern = Pattern.compile(".* id=\"(.*?)\".*");
-
-for (String expectedFile : expectedFiles) {
-    File file = new File(outputDir, expectedFile);
-    System.out.println("Checking for presence of " + file);
-    if (!file.isFile()) {
-        throw new Exception("Missing file " + file);
-    }
-
-    List lines = new ArrayList();
-    Closeable resource = null;
-    try {
-        String line = null;
-        BufferedReader bReader = new BufferedReader(resource = new FileReader(file));
-        while ((line = bReader.readLine()) != null) {
-            lines.add(line);
-        }
-    }
-    finally {
-       try {
-           resource.close();
-       }
-       catch (IOException ignore) {}
-    }
-
-    System.out.println("Ensuring IDs match TOC links");
-
-    List tocEntries = new ArrayList();
-
-    for (String line : lines) {
-        Matcher matcher = null;
-
-        matcher = tocEntry.matcher(line);
-        if (matcher.matches()) {
-            tocEntries.add(matcher.group(1));
-        }
-
-        matcher = elementIdPattern.matcher(line);
-        if (matcher.matches()) {
-            String elementId = matcher.group(1);
-            if (tocEntries.contains(elementId)) {
-                tocEntries.remove(tocEntries.indexOf(elementId));
-            }
-        }
-    }
-
-    if (tocEntries.size() != 0) {
-        throw new Exception("Couldn't find matching IDs for the following TOC entries: " + tocEntries);
-    }
-
-    boolean includeResolved = false;
-    boolean sourceHighlighted = false;
-
-    for (String line : lines) {
-        if (!includeResolved && line.contains("Content included from the file ")) {
-            includeResolved = true;
-        }
-        else if (!sourceHighlighted && line.contains("<span style=\"color:#070;font-weight:bold\">&lt;plugin&gt;</span>")) {
-            sourceHighlighted = true; 
-        }
-    }
-
-    if (!includeResolved) {
-        throw new Exception("Include file was not resolved.");
-    }
-
-    if (!sourceHighlighted) {
-        throw new Exception("Source code was not highlighted.");
-    }
-
-    // validate that maven properties are replaced same as attributes
-    boolean foundReplacement = false;
-    for (String line: lines) {
-    System.out.println(line);
-        if (line.contains("v1.2.3")) {
-            foundReplacement = true;
-            break;
-        }
-    }
-    if (!foundReplacement) {
-        throw new Exception("Maven properties not replaced.");
-    }
-}
-
-for (String unexpectedFile : unexpectedFiles) {
-    File file = new File(outputDir, unexpectedFile);
-    System.out.println("Checking for absence of " + file);
-    if (file.isFile()) {
-        throw new Exception("Unexpected file " + file);
-    }
-}
-
-return true;
diff --git a/src/it/maven-site-plugin/validate.groovy b/src/it/maven-site-plugin/validate.groovy
new file mode 100644
index 0000000..372b087
--- /dev/null
+++ b/src/it/maven-site-plugin/validate.groovy
@@ -0,0 +1,153 @@
+import java.nio.charset.StandardCharsets
+import java.nio.file.Files
+import java.util.regex.Pattern
+
+File outputDir = new File(basedir, "target/site")
+
+String[] expectedFiles = [
+        "file-with-toc.html"
+]
+
+String[] unexpectedFiles = [
+        "_include.html"
+]
+
+class PatternCapturer {
+
+    private final Pattern pattern
+    private final List<String> hits
+
+    PatternCapturer(String pattern) {
+        this.pattern = Pattern.compile(pattern)
+        this.hits = new ArrayList()
+    }
+
+
+    String tryMatches(String text) {
+        def matcher = pattern.matcher(text)
+        if (matcher.matches()) {
+            def group = matcher.group(1)
+            hits.add(group)
+            return group
+        }
+        return null
+    }
+
+    void remove(String text) {
+        hits.remove(text)
+    }
+
+    int size() {
+        return hits.size()
+    }
+
+    List<String> getHits() {
+        return hits
+    }
+}
+
+for (String expectedFile : expectedFiles) {
+    File file = new File(outputDir, expectedFile)
+    System.out.println("Checking for presence of $file")
+    if (!file.exists() || !file.isFile()) {
+        throw new Exception("Missing file $file")
+    }
+
+    def lines = Files.readAllLines(file.toPath(), StandardCharsets.UTF_8)
+    System.out.println("Ensuring IDs match TOC links")
+
+    // == Assert TOC ==
+    def tocEntryCapturer = new PatternCapturer("<li><a href=\"#(.*?)\">.*")
+    def elementIdCapturer = new PatternCapturer(".* id=\"(.*?)\".*")
+
+    for (String line : lines) {
+        tocEntryCapturer.tryMatches(line)
+
+        String match = elementIdCapturer.tryMatches(line)
+        if (match != null) {
+            tocEntryCapturer.remove(match)
+        }
+    }
+    if (tocEntryCapturer.size() != 0) {
+        throw new Exception("Couldn't find matching IDs for the following TOC entries: ${tocEntryCapturer.getHits()}")
+    }
+
+    // == Assert includes ==
+    boolean includeResolved = false
+    boolean sourceHighlighted = false
+
+    for (String line : lines) {
+        if (!includeResolved && line.contains("Content included from the file ")) {
+            includeResolved = true
+        } else if (!sourceHighlighted && line.contains("<span style=\"color:#070;font-weight:bold\">&lt;plugin&gt;</span>")) {
+            sourceHighlighted = true;
+        }
+    }
+    if (!includeResolved) {
+        throw new Exception("Include file was not resolved")
+    }
+    if (!sourceHighlighted) {
+        throw new Exception("Source code was not highlighted")
+    }
+
+    // validate that maven properties are replaced same as attributes
+    boolean foundReplacement = false
+    for (String line : lines) {
+        if (line.contains("v1.2.3")) {
+            foundReplacement = true
+            break
+        }
+    }
+    if (!foundReplacement) {
+        throw new Exception("Maven properties not replaced")
+    }
+
+    // == Assert header metadata ==
+    def metaPattern = Pattern.compile( "<meta name=\"(author|date)\" content=\"(.*)\" />")
+    boolean headFound = false
+    Map<String,String> metaTags = new HashMap<>()
+
+    for (String line : lines) {
+        if (!headFound) {
+            headFound = line.endsWith("<head>")
+            continue
+        }
+        if (line.endsWith("</head>")) break
+        def matcher = metaPattern.matcher(line.trim())
+        if (matcher.matches()) {
+            metaTags.put(matcher.group(1), matcher.group(2))
+        }
+    }
+
+    if (metaTags['author'] != 'The Author')
+        throw new RuntimeException("Author not found in $metaTags")
+    if (metaTags['date'] != '2024-02-07 23:36:29')
+        throw new RuntimeException("docdatetime not found in: $metaTags")
+
+    // assert breadcrumbs
+    boolean breadcrumbTagFound = false
+    boolean breadcrumbFound = false
+    final String docTitle = "File with TOC"
+
+    for (String line : lines) {
+        if (!breadcrumbTagFound) {
+            breadcrumbTagFound = line.endsWith("<div id=\"breadcrumbs\">")
+            continue
+        }
+        if (line.endsWith("</div>")) break
+        breadcrumbFound = line.trim().equals(docTitle)
+    }
+
+    if (!breadcrumbFound)
+        throw new RuntimeException("No breadcrumb found: expected title: $docTitle")
+}
+
+for (String unexpectedFile : unexpectedFiles) {
+    File file = new File(outputDir, unexpectedFile)
+    System.out.println("Checking for absence of $file")
+    if (file.isFile()) {
+        throw new Exception("Unexpected file $file")
+    }
+}
+
+return true
diff --git a/src/main/java/org/asciidoctor/maven/site/AsciidoctorDoxiaParser.java b/src/main/java/org/asciidoctor/maven/site/AsciidoctorDoxiaParser.java
index 65ee0c8..15bfb70 100644
--- a/src/main/java/org/asciidoctor/maven/site/AsciidoctorDoxiaParser.java
+++ b/src/main/java/org/asciidoctor/maven/site/AsciidoctorDoxiaParser.java
@@ -5,11 +5,15 @@ import org.apache.maven.doxia.parser.ParseException;
 import org.apache.maven.doxia.parser.Parser;
 import org.apache.maven.doxia.sink.Sink;
 import org.apache.maven.project.MavenProject;
-import org.asciidoctor.*;
+import org.asciidoctor.Asciidoctor;
+import org.asciidoctor.AttributesBuilder;
+import org.asciidoctor.OptionsBuilder;
+import org.asciidoctor.SafeMode;
 import org.asciidoctor.maven.log.LogHandler;
 import org.asciidoctor.maven.log.LogRecordFormatter;
 import org.asciidoctor.maven.log.LogRecordsProcessors;
 import org.asciidoctor.maven.log.MemoryLogHandler;
+import org.asciidoctor.maven.site.SiteConverterDecorator.Result;
 import org.codehaus.plexus.component.annotations.Component;
 import org.codehaus.plexus.util.IOUtil;
 import org.codehaus.plexus.util.xml.Xpp3Dom;
@@ -75,8 +79,9 @@ public class AsciidoctorDoxiaParser extends AbstractTextParser {
         final LogHandler logHandler = getLogHandlerConfig(siteConfig);
         final MemoryLogHandler memoryLogHandler = asciidoctorLoggingSetup(asciidoctor, logHandler, siteDirectory);
 
-        // QUESTION should we keep OptionsBuilder & AttributesBuilder separate for call to convertAsciiDoc?
-        String asciidocHtml = convertAsciiDoc(asciidoctor, source, conversionConfig.getOptions());
+        final SiteConverterDecorator siteConverter = new SiteConverterDecorator(asciidoctor);
+        final Result headerMetadata = siteConverter.process(source, conversionConfig.getOptions());
+
         try {
             // process log messages according to mojo configuration
             new LogRecordsProcessors(logHandler, siteDirectory, errorMessage -> getLog().error(errorMessage))
@@ -85,9 +90,13 @@ public class AsciidoctorDoxiaParser extends AbstractTextParser {
             throw new ParseException(exception.getMessage(), exception);
         }
 
-        sink.rawText(asciidocHtml);
+        new HeadParser(sink)
+                .parse(headerMetadata.getHeaderMetadata());
+
+        sink.rawText(headerMetadata.getHtml());
     }
 
+
     private MemoryLogHandler asciidoctorLoggingSetup(Asciidoctor asciidoctor, LogHandler logHandler, File siteDirectory) {
 
         final MemoryLogHandler memoryLogHandler = new MemoryLogHandler(logHandler.getOutputToConsole(), siteDirectory,
@@ -140,9 +149,4 @@ public class AsciidoctorDoxiaParser extends AbstractTextParser {
             }
         }
     }
-
-    protected String convertAsciiDoc(Asciidoctor asciidoctor, String source, Options options) {
-        return asciidoctor.convert(source, options);
-    }
-
 }
diff --git a/src/main/java/org/asciidoctor/maven/site/HeadParser.java b/src/main/java/org/asciidoctor/maven/site/HeadParser.java
new file mode 100644
index 0000000..08c253d
--- /dev/null
+++ b/src/main/java/org/asciidoctor/maven/site/HeadParser.java
@@ -0,0 +1,32 @@
+package org.asciidoctor.maven.site;
+
+import org.apache.maven.doxia.sink.Sink;
+
+import java.util.Optional;
+
+class HeadParser {
+
+    private final Sink sink;
+
+    HeadParser(Sink sink) {
+        this.sink = sink;
+    }
+
+    void parse(SiteConverterDecorator.HeaderMetadata headerMetadata) {
+        sink.head();
+        sink.title();
+        sink.text(Optional.ofNullable(headerMetadata.getTitle()).orElse("[Untitled]"));
+        sink.title_();
+
+        for (String author : headerMetadata.getAuthors()) {
+            sink.author();
+            sink.text(author.toString());
+            sink.author_();
+        }
+
+        sink.date();
+        sink.text(headerMetadata.getDateTime());
+        sink.date_();
+        sink.head_();
+    }
+}
diff --git a/src/main/java/org/asciidoctor/maven/site/SiteConverterDecorator.java b/src/main/java/org/asciidoctor/maven/site/SiteConverterDecorator.java
new file mode 100644
index 0000000..fa174a4
--- /dev/null
+++ b/src/main/java/org/asciidoctor/maven/site/SiteConverterDecorator.java
@@ -0,0 +1,104 @@
+package org.asciidoctor.maven.site;
+
+import org.asciidoctor.Asciidoctor;
+import org.asciidoctor.Options;
+import org.asciidoctor.OptionsBuilder;
+import org.asciidoctor.ast.Author;
+import org.asciidoctor.ast.Document;
+import org.asciidoctor.ast.RevisionInfo;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+/**
+ * Asciidoctor conversion wrapper for maven-site integration.
+ * In addition to conversion, handles header metadata extraction.
+ */
+class SiteConverterDecorator {
+
+    private final Asciidoctor asciidoctor;
+
+    SiteConverterDecorator(Asciidoctor asciidoctor) {
+        this.asciidoctor = asciidoctor;
+    }
+
+    Result process(String content, Options options) {
+        Document document = asciidoctor.load(content, headerProcessingMetadata(options));
+
+        String title = document.getTitle();
+        List<String> authors = extractAuthors(document);
+        String documentDateTime = extractDocumentDateTime(document, document.getAttributes());
+
+        String html = asciidoctor.convert(content, options);
+        return new Result(new HeaderMetadata(title, authors, documentDateTime), html);
+    }
+
+    private static Options headerProcessingMetadata(Options options) {
+        Map<String, Object> optionsMap = options.map();
+        OptionsBuilder builder = Options.builder();
+        for (Map.Entry<String, Object> entry : optionsMap.entrySet()) {
+            builder.option(entry.getKey(), entry.getValue());
+        }
+
+        builder.parseHeaderOnly(Boolean.TRUE);
+        return builder.build();
+    }
+
+    private List<String> extractAuthors(Document document) {
+        return document.getAuthors().stream()
+                .map(Author::toString)
+                .collect(Collectors.toList());
+    }
+
+    private String extractDocumentDateTime(Document document, Map<String, Object> attributes) {
+        final RevisionInfo revisionInfo = document.getRevisionInfo();
+        return Optional.ofNullable(revisionInfo.getDate())
+                .orElse((String) attributes.get("docdatetime"));
+    }
+
+    final class Result {
+
+        private final HeaderMetadata headerMetadata;
+        private final String html;
+
+        Result(HeaderMetadata headerMetadata, String html) {
+            this.headerMetadata = headerMetadata;
+            this.html = html;
+        }
+
+        HeaderMetadata getHeaderMetadata() {
+            return headerMetadata;
+        }
+
+        String getHtml() {
+            return html;
+        }
+    }
+
+    final class HeaderMetadata {
+
+        private final String title;
+        private final List<String> authors;
+        private final String dateTime;
+
+        HeaderMetadata(String title, List<String> authors, String dateTime) {
+            this.title = title;
+            this.authors = authors;
+            this.dateTime = dateTime;
+        }
+
+        String getTitle() {
+            return title;
+        }
+
+        List<String> getAuthors() {
+            return authors;
+        }
+
+        String getDateTime() {
+            return dateTime;
+        }
+    }
+}


Test patch:
diff --git a/src/test/java/org/asciidoctor/maven/site/SiteConverterDecoratorTest.java b/src/test/java/org/asciidoctor/maven/site/SiteConverterDecoratorTest.java
new file mode 100644
index 0000000..3964711
--- /dev/null
+++ b/src/test/java/org/asciidoctor/maven/site/SiteConverterDecoratorTest.java
@@ -0,0 +1,160 @@
+package org.asciidoctor.maven.site;
+
+import org.asciidoctor.Asciidoctor;
+import org.asciidoctor.Attributes;
+import org.asciidoctor.Options;
+import org.asciidoctor.SafeMode;
+import org.asciidoctor.maven.site.SiteConverterDecorator.HeaderMetadata;
+import org.asciidoctor.maven.site.SiteConverterDecorator.Result;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.util.Collections;
+import java.util.Map;
+import java.util.stream.Stream;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class SiteConverterDecoratorTest {
+
+    private final Asciidoctor asciidoctor = Asciidoctor.Factory.create();
+
+    @ParameterizedTest
+    @ValueSource(strings = {
+            "= Hello, AsciiDoc!",
+            "# Hello, AsciiDoc!",
+            "Hello, AsciiDoc!\n================"
+    })
+    void should_extract_title_from_header(String title) {
+        SiteConverterDecorator siteConverter = new SiteConverterDecorator(asciidoctor);
+
+        Options options = defaultOptions();
+        Result result = siteConverter.process(title + "\n", options);
+
+        HeaderMetadata headerMetadata = result.getHeaderMetadata();
+        assertThat(headerMetadata.getTitle()).isEqualTo("Hello, AsciiDoc!");
+        assertThat(result.getHtml()).isNotBlank();
+    }
+
+    @Test
+    void should_extract_title_from_attribute() {
+        SiteConverterDecorator siteConverter = new SiteConverterDecorator(asciidoctor);
+
+        Options options = Options.builder()
+                .safe(SafeMode.UNSAFE)
+                .backend("xhtml")
+                .attributes(Attributes.builder()
+                        .attribute("idprefix", "@")
+                        .attribute("showtitle", "@")
+                        .attribute("who", "me")
+                        .build())
+                .build();
+        Result result = siteConverter.process("= Hello, {who}!\n", options);
+
+        HeaderMetadata headerMetadata = result.getHeaderMetadata();
+        assertThat(headerMetadata.getTitle()).isEqualTo("Hello, me!");
+        assertThat(result.getHtml()).isNotBlank();
+    }
+
+
+    @ParameterizedTest
+    @MethodSource("authorsProvider")
+    void should_extract_author(String content, String expected) {
+        SiteConverterDecorator siteConverter = new SiteConverterDecorator(asciidoctor);
+
+        Options options = defaultOptions();
+        Result result = siteConverter.process(content + "\n", options);
+
+        HeaderMetadata headerMetadata = result.getHeaderMetadata();
+        assertThat(headerMetadata.getAuthors())
+                .containsExactly(expected);
+        assertThat(result.getHtml()).isNotBlank();
+    }
+
+    private static Stream<Arguments> authorsProvider() {
+        return Stream.of(
+                Arguments.of("= Title\nAuthor YesMe", "Author YesMe"),
+                Arguments.of("= Title\nAuthor Romero_Encinas", "Author Romero Encinas"),
+                Arguments.of("= Title\n:author: Author Romero_Encinas", "Author Romero Encinas"),
+                Arguments.of("= Title\nAuthor_Me <mail@mail.com>", "Author Me <mail@mail.com>")
+        );
+    }
+
+    @Test
+    void should_extract_author_from_attribute() {
+        SiteConverterDecorator siteConverter = new SiteConverterDecorator(asciidoctor);
+
+        String content = "= Hello, AsciiDoc!";
+        Options options = optionsWithAttributes(Collections.singletonMap("author", "From Attr"));
+        Result result = siteConverter.process(content + "\n", options);
+
+        HeaderMetadata headerMetadata = result.getHeaderMetadata();
+        assertThat(headerMetadata.getAuthors())
+                .containsExactly("From Attr");
+        assertThat(result.getHtml()).isNotBlank();
+    }
+
+    @Test
+    void should_extract_multiple_authors() {
+        SiteConverterDecorator siteConverter = new SiteConverterDecorator(asciidoctor);
+
+        String content = "= Hello, AsciiDoc!\nfirstname1 lastname2; firstname3 middlename4 lastname5";
+        Result result = siteConverter.process(content + "\n", defaultOptions());
+
+        HeaderMetadata headerMetadata = result.getHeaderMetadata();
+        assertThat(headerMetadata.getAuthors())
+                .containsExactlyInAnyOrder("firstname1 lastname2", "firstname3 middlename4 lastname5");
+        assertThat(result.getHtml()).isNotBlank();
+    }
+
+    @Test
+    void should_extract_datetime_generated() {
+        SiteConverterDecorator siteConverter = new SiteConverterDecorator(asciidoctor);
+
+        String content = "= Hello, AsciiDoc!";
+        Result result = siteConverter.process(content + "\n", defaultOptions());
+
+        HeaderMetadata headerMetadata = result.getHeaderMetadata();
+        assertThat(headerMetadata.getDateTime()).matches("(\\d{4})-(\\d{2})-(\\d{2}) (\\d{2}):(\\d{2}):(\\d{2}) .*");
+        assertThat(result.getHtml()).isNotBlank();
+    }
+
+    @Test
+    void should_extract_datetime_from_attribute() {
+        SiteConverterDecorator siteConverter = new SiteConverterDecorator(asciidoctor);
+
+        String content = "= Hello, AsciiDoc!";
+        Options options = optionsWithAttributes(Collections.singletonMap("docdatetime", "2024-11-22"));
+        Result result = siteConverter.process(content + "\n", options);
+
+        HeaderMetadata headerMetadata = result.getHeaderMetadata();
+        assertThat(headerMetadata.getDateTime()).isEqualTo("2024-11-22");
+        assertThat(result.getHtml()).isNotBlank();
+    }
+
+    private Options defaultOptions() {
+        return Options.builder()
+                .safe(SafeMode.UNSAFE)
+                .backend("xhtml")
+                .attributes(defaultAttributes())
+                .build();
+    }
+
+    private static Attributes defaultAttributes() {
+        return Attributes.builder()
+                .attribute("idprefix", "@")
+                .attribute("showtitle", "@")
+                .build();
+    }
+
+    private Options optionsWithAttributes(Map<String, String> attributes) {
+        Options options = defaultOptions();
+        Attributes attr = defaultAttributes();
+        attributes.forEach((k, v) -> attr.setAttribute(k, v));
+        options.setAttributes(attr);
+        return options;
+    }
+}