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));
}
}
}