NamedEntityExtension.java

/*
 * Copyright © 2026 The CTAN Team and individual authors
 *
 * This file is distributed under the 3-clause BSD license.
 * See file LICENSE for details.
 */

package org.ctan.markup.gfm.ext;

import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;

import org.commonmark.parser.Parser;
import org.commonmark.renderer.html.HtmlRenderer;

/**
 * CommonMark extension that translates named HTML entities ({@code &Name;})
 * into configurable HTML snippets, and optionally replaces the same names when
 * they appear as plain text (outside code spans and code blocks).
 *
 * <p>
 * The extension intercepts entity-like tokens at <em>parse time</em> (via an
 * {@link org.commonmark.parser.beta.InlineContentParserFactory}) so they become
 * proper AST nodes ({@link NamedEntityNode}) rather than raw text. At render
 * time a custom {@link org.commonmark.renderer.NodeRenderer} emits the mapped
 * HTML, or falls back to the original {@code &Name;} literal for unknown names.
 *
 * <p>
 * When {@link Builder#replaceLogoText(boolean) replaceLogoText(true)} is set a
 * {@link LogoTextPostProcessor} is also registered: after parsing it walks the
 * AST and replaces plain-text occurrences of logo names (e.g. bare
 * {@code LaTeX} in a sentence) with {@link NamedEntityNode}s, so they receive
 * the same styled HTML. Text inside code spans and code blocks is never
 * touched.
 *
 * <p>
 * Usage example: <pre>{@code
 * var ext = NamedEntityExtension.builder()
 *   .entity("TeX",
 *      "<span class=\"t-logo\">T<span class=\"e\">e</span>X</span>")
 *   .entity("LaTeX",
 *      "<span class=\"t-logo\">L<sup>A</sup>T<span class=\"e\">e</span>X</span>")
 *   .replaceLogoText(true)
 *   .build();
 *
 * var extensions = List.of(ext, TablesExtension.create(), ...);
 * var parser   = Parser.builder().extensions(extensions).build();
 * var renderer = HtmlRenderer.builder().extensions(extensions).build();
 * }</pre>
 */
public final class NamedEntityExtension
    implements
        Parser.ParserExtension,
        HtmlRenderer.HtmlRendererExtension {

    /**
     * The class <code>Builder</code> contains the builder.
     */
    public static final class Builder {

        private final Map<String, String> entityMap = new LinkedHashMap<>();

        private boolean replaceLogoText = false;

        /**
         * The method <code>build</code> starts the building process.
         *
         * @return the entity
         */
        public NamedEntityExtension build() {

            if (entityMap.isEmpty()) {
                throw new IllegalStateException(
                    "At least one entity mapping is required");
            }
            NamedEntityExtension ext = new NamedEntityExtension(this);
            ext.replaceLogoText = replaceLogoText;
            return ext;
        }

        /**
         * Registers multiple entities at once from the supplied map.
         *
         * @param entities map of entity name to HTML replacement
         * @return this builder for chaining
         */
        public Builder entities(Map<String, String> entities) {

            entities.forEach(this::entity);
            return this;
        }

        /**
         * Registers a named entity and its HTML replacement.
         *
         * @param name the entity name without {@code &} / {@code ;} (e.g.
         *     {@code "TeX"})
         * @param html the raw HTML to emit instead (e.g. a {@code <span>} tree)
         * @return this builder for chaining
         */
        public Builder entity(String name, String html) {

            if (name == null || name.isBlank()) {
                throw new IllegalArgumentException(
                    "Entity name must not be blank");
            }
            entityMap.put(name, html);
            return this;
        }

        /**
         * When set to {@code true}, plain-text occurrences of registered logo
         * names (e.g. a bare {@code LaTeX} in a paragraph) are also replaced
         * with the configured HTML, as if the author had written
         * {@code &LaTeX;}. Text inside code spans and code blocks is never
         * affected.
         *
         * <p>
         * Defaults to {@code false}.
         *
         * @param replace {@code true} to enable plain-text replacement
         * @return this builder for chaining
         */
        public Builder replaceLogoText(boolean replace) {

            this.replaceLogoText = replace;
            return this;
        }
    }

    /**
     * The method <code>builder</code> provides means to create a static
     * builder.
     *
     * @return the builder
     */
    public static Builder builder() {

        return new Builder();
    }

    /**
     * Returns the set of all registered entity names (used by the inline
     * parser).
     */
    static Set<String> knownEntityNames(NamedEntityExtension ext) {

        return ext.entityMap.keySet();
    }

    // -------------------------------------------------------------------------
    // Factory
    // -------------------------------------------------------------------------

    /** Unmodifiable map of entity name to raw HTML replacement. */
    private final Map<String, String> entityMap;

    // -------------------------------------------------------------------------
    // Parser.ParserExtension
    // -------------------------------------------------------------------------

    /**
     * When {@code true}, a {@link LogoTextPostProcessor} is registered that
     * replaces plain-text logo names (e.g. {@code LaTeX}) with
     * {@link NamedEntityNode}s, outside of code spans and code blocks.
     */
    private boolean replaceLogoText = true;

    // -------------------------------------------------------------------------
    // HtmlRenderer.HtmlRendererExtension
    // -------------------------------------------------------------------------

    private NamedEntityExtension(Builder builder) {

        this.entityMap = Map.copyOf(builder.entityMap);
        // this.replaceLogoText = builder.replaceLogoText;
    }

    // -------------------------------------------------------------------------
    // Builder
    // -------------------------------------------------------------------------

    @Override
    public void extend(HtmlRenderer.Builder rendererBuilder) {

        rendererBuilder.nodeRendererFactory(
            ctx -> new NamedEntityNodeRenderer(ctx, entityMap));
    }

    // -------------------------------------------------------------------------
    // Package-private helpers
    // -------------------------------------------------------------------------

    @Override
    public void extend(Parser.Builder parserBuilder) {

        parserBuilder.customInlineContentParserFactory(
            new NamedEntityInlineParser.Factory());
        if (replaceLogoText) {
            parserBuilder.postProcessor(new LogoTextPostProcessor(entityMap));
        }
    }
}