SectionEmitter.java

package docsite;

import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.time.LocalDate;
import java.util.*;
import java.util.regex.*;
import com.vdurmont.emoji.EmojiParser;
import docsite.util.*;
import j2html.tags.specialized.*;
import static docsite.util.EmitterUtil.*;
import static j2html.TagCreator.*;

public abstract class SectionEmitter {

    protected static final int TOC_MIN_LEVEL = 2;
    protected static final int TOC_MAX_LEVEL = 3;
    protected static final String INDEX_FILE = "index.html";


    protected static final Logger logger = Logger.instance();

    protected final Docsite site;
    protected final Section section;
    private final String origin;
    protected final ImageResolver sectionImages;
    protected final ImageResolver globalImages;
    protected final boolean useCDN;

    protected final SectionEmitter rootEmitter;
    protected final List<SectionEmitter> ancestorEmitters;
    protected final List<SectionEmitter> childEmitters;

    protected final ThemeColors themeColors;
    protected final Path outputFolder;
    protected final Path baseDir;
    protected final Map<String,String> metadata;
    protected final List<Script> scripts;
    protected final List<SiteLanguage> availableLanguages;
    protected final SiteLanguage siteLanguage;
    protected final Map<String,String> localization;


    protected SectionEmitter(EmitterBuildParams params) {
        this.site = params.site();
        this.section = params.section();
        this.rootEmitter = Objects.requireNonNullElse(params.rootEmitter(), this);
        this.ancestorEmitters= List.copyOf(params.ancestorEmitters());
        this.childEmitters = new ArrayList<>();
        this.origin = section.source();
        this.globalImages = params.globalImages();
        this.themeColors = params.themeColors();
        this.outputFolder = params.outputFolder();
        this.baseDir = params.baseDir();
        this.metadata = params.metadata();
        this.scripts = params.scripts();
        this.sectionImages = origin == null ? null : new ImageResolver(
            outputFolder.resolve("images").resolve(section.name()),
            baseDir.resolve(origin)
        );
        this.useCDN = params.useCDN();
        this.availableLanguages = params.availableLanguages();
        this.siteLanguage = params.siteLanguage();
        if (params.localization() != null) {
            this.localization = params.localization().get(params.siteLanguage().language());
        } else {
            this.localization = null;
        }
    }


    protected abstract String url();

    protected abstract String url(SiteLanguage language);

    public abstract ATag createLinkToSection(boolean withIcon);

    protected abstract SectionTag createSectionContent();

    protected abstract AsideTag createTableOfContents(SectionTag section);


    public String href() {
        return EmitterUtil.href(section.name());
    }

    protected Path outputPath() {
        return outputFolder.resolve(url(siteLanguage));
    }

    protected String origin() {
        return origin;
    }

    public void emitHTML() throws IOException {
        emitHTML(false);
    }


    public void emitHTML(boolean includeFooter) throws IOException {

        if (!section.isValid(baseDir)) {
            logger.warn(
                "Section {} is not valid and would not be included: {}",
                section.name(),
                section.validation(baseDir)
            );
            return;
        }

        HeaderTag header = createHeader();
        NavTag burgerMenu = createBurgerMenu();
        DivTag info = div().withClass("info").with(createBreadcrumbs(), createLanguageSelection());
        SectionTag sectionContent = createSectionContent().withId("content");
        if (includeFooter) {
            sectionContent.with(rawHtml(
                "<div class=\"footer\">"+
                    "Generated with <a href=\"https://luiinge.github.io/docsite-maven-plugin/\" target=\"_blank_\">Docsite</a>. "+
                    "Last published on "+ LocalDate.now()+
                    "</div>"
            ));
        }

        AsideTag tableOfContents = createTableOfContents(sectionContent);
        if (tableOfContents.getNumChildren() == 0) {
            tableOfContents.withClass("empty");
        }
        tableOfContents.attr("onclick","hideTocIfVisible(event,this)");
        DivTag tocButton = createTableOfContentsButton(tableOfContents.getNumChildren() == 0);



        HeadTag head = htmlHead();
        if (useCDN) {
            addPrismComponents(head, sectionContent);
        }

        HtmlTag htmlObject = html().with(
            head,
            body()
                .withCondClass(tableOfContents.getNumChildren() == 0, "no-toc")
                .with(
                    jumpToContentButton(),
                    header,
                    burgerMenu,
                    info,
                    tocButton,
                    tableOfContents,
                    sectionContent
                )
        )
        .withLang(this.siteLanguage.language());
        writeToFile(htmlObject);

        for (SectionEmitter child : childEmitters) {
            child.emitHTML();
        }
    }


    private ATag jumpToContentButton() {
        return a().withId("jump-to-content").withText("jump to content").withHref("#content");
    }


    private HeaderTag createHeader() {
        return header().with(
            createLogoAndTitle(),
            div().withClass("filler"),
            createExpandedMenu(),
            createMenuButton(),
            createCompanyLogo()
        );
    }


    public NavTag createBreadcrumbs() {

        String index = EmitterUtil.withLanguage(siteLanguage, INDEX_FILE);
        OlTag container = ol();
        container.with(li().with(internalLink(translate(site.title()),index)));

        if (!ancestorEmitters.isEmpty()) {
            Iterator<SectionEmitter> iterator = ancestorEmitters.iterator();
            // first section in ancestors is always the index
            SectionEmitter path;
            iterator.next();

            while (iterator.hasNext()) {
                path = iterator.next();
                if (path.section.isValid(baseDir)) {
                    container.with(li().with(path.createLinkToSection(false)));
                } else {
                    container.with(li().with(a(translate(path.section.name()))));
                }
            }

            container.with(li().with(a(translate(section.name()))));
        }

       return nav().withClass("breadcrumbs").with(container);
    }




    private SpanTag createLanguageSelection() {
        var options = availableLanguages.stream()
            .map(it ->
                option(it.display())
                    .withValue(it.language())
                    .withCondSelected(it.equals(siteLanguage))
            )
            .toArray(OptionTag[]::new);
        if (options.length == 0) {
            return span();
        }
        var languageSelection = select().with(options);
        languageSelection.attr("onchange","redirectLanguage(this.value)");

        List<String> scriptLines = new ArrayList<>();
        scriptLines.add("function redirectLanguage(language) {");
        for (var language : availableLanguages) {
            scriptLines.add("if (language==='"+language.language()+"') location.href = '"+url(language)+"';");
        }
        scriptLines.add("}");
        ScriptTag scriptTag = script(String.join("\n", scriptLines));

        return span().withClass("language-selection").with(scriptTag,languageSelection);
    }



    private DivTag createCompanyLogo() {
        if (site.companyLogo() != null && site.companyLink() != null) {
            return div().withClass("company").with(
                EmitterUtil.externalLinkWithIcon(baseDir,"",site.companyLink(),site.companyLogo(),globalImages)
            );
        } else if (site.companyLink() == null) {
            return div().withClass("company").with(
                EmitterUtil.image(baseDir,site.companyLogo(),globalImages)
            );
        } else {
            return div();
        }
    }




    private HeadTag htmlHead() {
        String title = site.title();
        if (!section.name().equalsIgnoreCase(title) && !section.name().equals("index")) {
            title += " - "+section.name();
        }
        String description = section.description();
        if (description == null || description.isEmpty()) {
            description = site.description();
        }

        HeadTag head = head()
            .with(title(title))
            .with(meta().withName("description").withContent(description))
            .with(meta().withCharset("UTF-8"))
            .with(meta().withName("viewport").withContent("width=device-width, initial-scale=1.0"))
            .with(link().withRel("profile").withHref("http://www.w3.org/2005/10/profile"))
            ;
        if (site.favicon() != null) {
            head.with(
                link()
                    .withRel("icon")
                    .withType(globalImages.typeOf(site.favicon()))
                    .withHref(globalImages.imageFile(site.favicon()))
            );
        }
        if (useCDN) {
            CDNResources.css("fontawesome.min").ifPresent(head::with);
            CDNResources.css("prism.min").ifPresent(head::with);
            CDNResources.js("prism.min").ifPresent(head::with);
            CDNResources.js("mermaid").ifPresent(head::with);
            CDNResources.js("katex").ifPresent(head::with);
            CDNResources.css("katex").ifPresent(head::with);
        } else {
            head.with(stylesheet("css/font-awesome-all.css"));
            head.with(script().attr("src","js/prism.js"));
            head.with(stylesheet("css/prism.min.css"));
        }

        head.with(stylesheet("css/common.css"));
        head.with(stylesheet("css/layout.css"));
        head.with(stylesheet("css/theme.css"));
        head.with(stylesheet("css/extra-style.css"));
        head.with(script().attr("src","js/menu.js"));


        String themeStyle =
            ":root {\n"+
                "--menu-regular-background-color: "+themeColors.menuRegularBackgroundColor()+";\n"+
                "--menu-bold-background-color: "+themeColors.menuBoldBackgroundColor()+";\n"+
                "--menu-foreground-color: "+themeColors.menuForegroundColor()+";\n"+
                "--menu-decoration-color: "+themeColors.menuDecorationColor()+";\n"+
                "--gui-element-color: "+themeColors.guiElementColor()+";\n"+
                "}";
        head.with(style(themeStyle));

        this.metadata.forEach(
            (key,value) -> head.with(meta().withName(key).withContent(value))
        );

        for (Script script : this.scripts) {
            if (script.code() != null && !script.code().isBlank()) {
                head.with(script(script.code()));
            } else {
                head.with(script().withSrc(script.src()).withCondAsync(script.async()));
            }
        }


        return head;
    }



    void addChildEmitter(SectionEmitter child) {
        this.childEmitters.add(child);
    }





    private DivTag createLogoAndTitle() {
        boolean hasSubtitle = (site.description() != null && !site.description().isBlank());
        return div().withClass("title-and-subtitle")
            .with(
                EmitterUtil.icon(baseDir, site.logo(), globalImages),
                div().with(
                    h1(translate(site.title())).withClass(hasSubtitle ? "title" : "title no-subtitle")
                ).withClass("title-container"),
                span(translate(site.description())).withClass("subtitle")
            );
    }



    private DivTag createMenuButton() {
        if (rootEmitter.childEmitters.isEmpty()) {
            return div().withClass("hidden");
        }
        return div().withStyle("display: flex; align-items: center;")
            .with(a().withHref("#").withClasses("menu-button").attr("onclick","showOrHideMenu(event,this)"));
    }


    private NavTag createBurgerMenu() {
        return nav().withClasses("menu hidden burger-menu")
            .with(
                ul().with(
                    rootEmitter.childEmitters.stream()
                        .filter(it -> it.section.isValid(baseDir))
                        .map(it -> it.createMenuItem(it == this))
                        .toArray(LiTag[]::new)
                )
            );
    }


    private NavTag createExpandedMenu() {
        if (rootEmitter.childEmitters.isEmpty()) {
            return nav().withClass("hidden");
        }
        return nav().withClasses("expanded-menu menu")
            .with(
                ul().with(
                    rootEmitter.childEmitters.stream()
                        .filter(it -> it.section.isValid(baseDir))
                        .map(it -> it.createExpandedMenuItem(it == this, 0))
                        .toArray(LiTag[]::new)
                )
            );
    }


    private DivTag createTableOfContentsButton(boolean empty) {
        return div().withClass(empty ? "toc-button no-toc" : "toc-button").with(
            a().withHref("#")
        ).attr("onclick","showOrHideToc(event,this)");
    }


    private LiTag createMenuItem(boolean selected) {

        if (!childEmitters.isEmpty()) {
            UlTag dropdownMenu = ul().withClasses("dropdown", selected ? "visible" : "hidden");
            for (SectionEmitter child : childEmitters) {
                if (child.section.subsections() != null && !child.section.subsections().isEmpty()) {
                    dropdownMenu.with(child.createMenuItem(selected));
                } else {
                    dropdownMenu.with(li().with(child.createLinkToSection(true)));
                }
            }
            return li()
                .withClass(selected ? "selected expandable collapsed" : "expandable collapsed")
                .with(createLinkToSection(true).withHref("#"))
                .with(dropdownMenu)
                .attr("onclick","expandOrCollapse(event,this);");
        } else {
            return li()
                .withCondClass(selected, "selected")
                .with(createLinkToSection(true));
        }

    }



    private LiTag createExpandedMenuItem(boolean selected, int level) {
        if (!childEmitters.isEmpty()) {
            UlTag dropdownMenu = ul().withClasses("dropdown", "hidden");
            for (SectionEmitter child : childEmitters) {
                if (child.section.subsections() != null && !child.section.subsections().isEmpty()) {
                    dropdownMenu.with(child.createExpandedMenuItem(selected, level+1));
                } else {
                    dropdownMenu.with(li().with(child.createLinkToSection(true)));
                }
            }
            return li()
                .withClass(selected ? "selected expandable collapsed" : "expandable collapsed")
                .with(createLinkToSection(true).withHref("#"))
                .with(dropdownMenu)
                .attr("onclick",
                    level == 0 ?
                        "expandOrCollapseExpandedMenu(event,this);" :
                        "expandOrCollapse(event,this);"
                );
        } else {
            return li()
                .withCondClass(selected, "selected")
                .with(createLinkToSection(true));
        }

    }





    private void writeToFile(HtmlTag htmlObject) throws IOException {
        String html = htmlObject.render();
        if (Boolean.TRUE.equals(section.replaceEmojis())) {
            html = EmojiParser.parseToUnicode(html);
        }
        Files.writeString(outputPath(), html, StandardCharsets.UTF_8);
        logger.info("Written file {}", outputPath());
    }


    private void addPrismComponents(HeadTag head, SectionTag section) {
        Pattern pattern = Pattern.compile("<code\\s*class=\"\\s*language-([^\\s\"]+)");
        String html = section.render();
        Matcher matcher = pattern.matcher(html);
        Set<String> languages = new HashSet<>();
        while (matcher.find()) {
            languages.add("prism."+matcher.group(1));
        }
        languages.forEach(lang -> CDNResources.js(lang).ifPresent(head::with));
    }



    protected String translate(String value) {
        if (this.localization == null || !this.localization.containsKey(value)) {
            return value;
        } else {
            return this.localization.get(value);
        }
    }


}