Injecting a Custom Dialect into Halo to Access HTML Templates
While developing a plugin, I wanted to obtain the entire HTML page, but found that the only available option was AdditionalWebFilter, by which point processing had already entered the response phase. I wanted to access the entire HTML page earlier, for example while the page was being rendered, but Halo does not expose such an interface.
Although I do not understand Spring or Thymeleaf very well, by continuously reading the source code and debugging, I learned that a Dialect could be used as an entry point. That led to the following toy implementation.
How It Works
Thymeleaf uses TemplateEngine to hold the HTML templates to be rendered, Dialects, and various configuration options. It also allows the rendering process to be extended by implementing IDialect.
Through the getProcessors method of IProcessorDialect, a subinterface of IDialect, you can provide a set of custom IProcessor implementations. During rendering, the doProcess method of these IProcessor instances will be executed.
IElementModelProcessor, a subinterface of IProcessor, provides access to the HTML currently being rendered. This makes it possible to manipulate the HTML and interfere with the rendering process.
By looking at Halo's built-in processors for handling tags, such as TemplateHeadProcessor, and tracing where they are referenced in the framework, it is easy to find that they are used in HaloProcessorDialect. An instance of this class is then constructed and cached in TemplateEngineManager. From this, we can determine that the key to accessing the entire HTML document in Halo is obtaining TemplateEngineManager and then using reflection to add a custom Dialect.
Obtaining TemplateEngineManager
The first step is to obtain TemplateEngineManager. Because this class is located in the application module, it is not exposed to plugins. In addition, plugins run in isolated child containers, so it cannot be obtained through ApplicationContext::getBean. We first need to obtain the parent container, which is the framework's context.
Inject ApplicationContext into a Bean, and then call getParent() to obtain the framework's context.
However, if you do this, you will find that TemplateEngineManager still cannot be obtained.
ApplicationContext root = applicationContext.getParent().getBean("templateEngineManager");
Executing the code above results in an error because the Bean cannot be found.
Since I did not understand how the container worked internally, I could only set a breakpoint and inspect what was actually stored in the parent container.
The BeanFactory there only contained the following Beans, and the target Bean was not among them.

Since I did not know the underlying mechanism, I started looking through the singleton objects to see what they contained.
When I reached extensionGetter, I found that it contained a beanFactory, and that beanFactory contained a large number of Beans. It looked like this was the Bean container for the entire framework.

Some interesting Beans:
systemEnvironment: stores Halo system environment informationenvironment: stores all environment information, including some system variablessystemInfoGetter: can be used to obtain system configuration, including some port informationsystemProperties: stores some system variablesextensionGetter: itsbeanFactorycontains a large number of BeanscryptoService: contains JWKs and other cryptography-related datauserDetailsService: user-related; can be used to obtain the key used for password encryption
So the following approach can be tried:
- Obtain the framework's main container through
ApplicationContext::getParent(). - Retrieve
extensionGetterfrom it, and then obtaintemplateEngineManagerthrough thebeanFactoryheld byextensionGetter.
The call chain therefore looks like this: PluginBeanContext::getParent -> FrameworkBeanContext::getBean -> extensionGetter::beanFactory::getBean -> TemplateEngineManager
ApplicationContext root = applicationContext.getParent();
Object extensionGetter = root.getBean("extensionGetter");
try {
Field beanFactoryField = extensionGetter.getClass().getDeclaredField("beanFactory");
beanFactoryField.setAccessible(true);
BeanFactory beanFactory = (BeanFactory) beanFactoryField.get(extensionGetter);
Object templateEngineManager = beanFactory.getBean("templateEngineManager");
} catch (Exception e) {
}
With the code above, templateEngineManager can be obtained successfully.

Implementing IProcessorDialect
The interface that needs to be implemented is IProcessorDialect. This can be done by extending AbstractProcessorDialect.
public class TestProcessorDialect extends AbstractProcessorDialect {
private static final String DIALECT_NAME = "testProcessorDialect";
public TestProcessorDialect() {
super(DIALECT_NAME, "", StandardDialect.PROCESSOR_PRECEDENCE);
}
@Override
public Set<IProcessor> getProcessors(String dialectPrefix) {
Set<IProcessor> set = new HashSet<>();
// Add the processor
set.add(new TestHTMLProcessor(dialectPrefix));
return set;
}
static class TestHTMLProcessor extends AbstractElementModelProcessor {
// Process the entire HTML document
private static final String TAG_NAME = "html";
// Priority
private static final int PRECEDENCE = 1000;
public TestHTMLProcessor(String dialectPrefix) {
super(TemplateMode.HTML, dialectPrefix, TAG_NAME, false, null, false, PRECEDENCE);
}
@Override
protected void doProcess(ITemplateContext context, IModel model,
IElementModelStructureHandler structureHandler) {
// do something
}
}
}
Injecting the Dialect
Halo adds Dialects in TemplateEngineManager. First, here is part of the TemplateEngineManager source code:
package run.halo.app.theme;
@Component
public class TemplateEngineManager {
private static final int CACHE_SIZE_LIMIT = 5;
private final ConcurrentLruCache<CacheKey, ISpringWebFluxTemplateEngine> engineCache;
private final ThymeleafProperties thymeleafProperties;
private final ExternalUrlSupplier externalUrlSupplier;
private final PluginManager pluginManager;
private final ObjectProvider<ITemplateResolver> templateResolvers;
private final ObjectProvider<IDialect> dialects;
private final ThemeResolver themeResolver;
public TemplateEngineManager(ThymeleafProperties thymeleafProperties,
ExternalUrlSupplier externalUrlSupplier,
PluginManager pluginManager, ObjectProvider<ITemplateResolver> templateResolvers,
ObjectProvider<IDialect> dialects, ThemeResolver themeResolver) {
...
this.dialects = dialects;
...
engineCache = new ConcurrentLruCache<>(CACHE_SIZE_LIMIT, this::templateEngineGenerator);
}
public ISpringWebFluxTemplateEngine getTemplateEngine(ThemeContext theme) {
CacheKey cacheKey = buildCacheKey(theme);
return engineCache.get(cacheKey);
}
private record CacheKey(String name, boolean active, ThemeContext context) {
}
CacheKey buildCacheKey(ThemeContext context) {
return new CacheKey(context.getName(), context.isActive(), context);
}
private ISpringWebFluxTemplateEngine templateEngineGenerator(CacheKey cacheKey) {
var engine = new HaloTemplateEngine(new ThemeMessageResolver(cacheKey.context()));
...
engine.addDialect(new HaloProcessorDialect());
...
dialects.orderedStream().forEach(engine::addDialect);
return engine;
}
}
Constructing a CacheKey requires a ThemeContext, and ThemeContext has the annotation @EqualsAndHashCode(of = "name"). In other words, engineCache stores a TemplateEngine cache for each theme. This class is used to manage the template engine cache for a specific theme, including HTML templates and various configuration options.
From engine.addDialect(new HaloProcessorDialect()); above, we can see that this step adds HaloProcessorDialect to HaloTemplateEngine, while HaloProcessorDialect returns processors through getProcessors for use during page rendering.
package run.halo.app.theme.dialect;
public class HaloProcessorDialect extends AbstractProcessorDialect
implements IExpressionObjectDialect, IPostProcessorDialect {
...
@Override
public Set<IProcessor> getProcessors(String dialectPrefix) {
final Set<IProcessor> processors = new HashSet<IProcessor>();
// add more processors
processors.add(new GlobalHeadInjectionProcessor(dialectPrefix));
processors.add(new TemplateFooterElementTagProcessor(dialectPrefix));
processors.add(new EvaluationContextEnhancer());
processors.add(new CommentElementTagProcessor(dialectPrefix));
processors.add(new CommentEnabledVariableProcessor());
processors.add(new InjectionExcluderProcessor());
return processors;
}
...
}
After clearing the template cache and debugging with breakpoints, the execution order can be observed. After a theme is installed or reloaded, the first page request follows this sequence: TemplateEngineManager::templateEngineGenerator -> HaloTemplateEngine::new, which generates the theme's template engine cache, followed by ThymeleafReactiveView::render -> TemplateEngine::getConfiguration -> TemplateEngine::initialize -> IProcessorDialect::getProcessors.
In other words, the TemplateEngine is initialized the first time the page is rendered. getProcessors is called only during initialization and is used to configure the TemplateEngine.
Because a manually injected Dialect will necessarily be injected after page initialization, the engine has to be initialized manually again after the injection. Otherwise, the custom IProcessorDialect#getProcessors method will not be called, the IProcessor will not be added to the TemplateEngine configuration, and its doProcess method will never execute.
So the hook approach is now: use reflection to obtain engineCache, iterate over all TemplateEngine instances in it, and call engine.addDialect() on each one to add the custom IProcessorDialect to the TemplateEngine.
One important detail is that addDialect cannot actually be called directly. This method checks whether the TemplateEngine has already been initialized and throws an exception if it has. Therefore, the initialized state needs to be set to false first, after which the engine can be initialized manually again.
public void addDialect(final IDialect dialect) {
Validate.notNull(dialect, "Dialect cannot be null");
checkNotInitialized();
this.dialectConfigurations.add(new DialectConfiguration(dialect));
}
The hook logic can be placed in the plugin main class's start method.
Complete implementation:
@Slf4j
@Component
public class TestPlugin extends BasePlugin {
private final ApplicationContext applicationContext;
public TestPlugin(ApplicationContext applicationContext) {
this.applicationContext = applicationContext;
}
@Override
public void start() {
ApplicationContext root =
applicationContext.getParent();
Object extensionGetter = root.getBean("extensionGetter");
try {
Field beanFactoryField = extensionGetter.getClass().getDeclaredField("beanFactory");
beanFactoryField.setAccessible(true);
BeanFactory beanFactory = (BeanFactory) beanFactoryField.get(extensionGetter);
Object templateEngineManager = beanFactory.getBean("templateEngineManager");
Class<?> aClass = templateEngineManager.getClass();
Field engineCacheField = aClass.getDeclaredField("engineCache");
engineCacheField.setAccessible(true);
ConcurrentLruCache engineCache =
(ConcurrentLruCache) engineCacheField.get(templateEngineManager);
Field cacheField = engineCache.getClass().getDeclaredField("cache");
cacheField.setAccessible(true);
ConcurrentMap map = (ConcurrentMap) cacheField.get(engineCache);
map.values().forEach(value -> {
TemplateEngine templateEngine;
try {
Method valueMethod;
valueMethod = value.getClass().getDeclaredMethod("getValue");
valueMethod.setAccessible(true);
templateEngine = (TemplateEngine) valueMethod.invoke(value);
Field initialized =
templateEngine.getClass().getSuperclass().getSuperclass().getSuperclass()
.getDeclaredField("initialized");
initialized.setAccessible(true);
initialized.set(templateEngine, false);
templateEngine.addDialect(new TestProcessorDialect());
Method initialize =
templateEngine.getClass().getSuperclass().getSuperclass().getSuperclass()
.getDeclaredMethod("initialize");
initialize.setAccessible(true);
initialize.invoke(templateEngine);
} catch (NoSuchMethodException | InvocationTargetException |
IllegalAccessException | NoSuchFieldException e) {
}
});
} catch (NoSuchFieldException | IllegalAccessException e) {
}
}
@Override
public void stop() {
// TODO: clean up the injected objects
}
}
By inspecting the breakpoint, the doProcess method in the implemented TestHTMLProcessor can be seen executing successfully.
Refactoring
At this point, I realized that I had overlooked something earlier: engineCache is a ConcurrentLruCache, and it is assigned as follows: engineCache = new ConcurrentLruCache<>(CACHE_SIZE_LIMIT, this::templateEngineGenerator).
Looking at the get method of ConcurrentLruCache, when no cached value exists, it uses its generator, which in this case is the supplied this::templateEngineGenerator, to construct and cache the object.
Since the object-construction function itself is supplied as a parameter, we can actually intervene directly in the method that generates the TemplateEngine. In other words, we can hook templateEngineGenerator by replacing the generator field in ConcurrentLruCache with our own Function implementation. After doing this, there is no longer any need to manually call TemplateEngine::initialize.
@Slf4j
@Component
public class TestPlugin extends BasePlugin {
private final ApplicationContext applicationContext;
public TestPlugin(ApplicationContext applicationContext) {
this.applicationContext = applicationContext;
}
@Override
public void start() {
try {
Object extensionGetter = getExtensionGetter();
BeanFactory beanFactory = getBeanFactory(extensionGetter);
Object templateEngineManager = beanFactory.getBean("templateEngineManager");
ConcurrentLruCache<?, ?> engineCache = getEngineCache(templateEngineManager);
injectGenerator(engineCache, templateEngineManager);
log.info("TestPlugin: template engine dialect injection completed.");
} catch (Exception e) {
log.error("TestPlugin: failed to inject template engine dialect.", e);
throw new IllegalStateException("Dialect injection failure", e);
}
}
@Override
public void stop() {
// TODO: restore original generator if needed
log.info("TestPlugin stopped. (restore logic not implemented)");
}
private Object getExtensionGetter() {
ApplicationContext root = applicationContext.getParent();
if (root == null) {
throw new IllegalStateException("Root ApplicationContext is null.");
}
return root.getBean("extensionGetter");
}
private BeanFactory getBeanFactory(Object extensionGetter)
throws NoSuchFieldException, IllegalAccessException {
Field field = extensionGetter.getClass().getDeclaredField("beanFactory");
field.setAccessible(true);
Object result = field.get(extensionGetter);
if (!(result instanceof BeanFactory)) {
throw new IllegalStateException("beanFactory field is not a BeanFactory");
}
return (BeanFactory) result;
}
private ConcurrentLruCache<?, ?> getEngineCache(Object templateEngineManager)
throws NoSuchFieldException, IllegalAccessException {
Field field = templateEngineManager.getClass().getDeclaredField("engineCache");
field.setAccessible(true);
Object cache = field.get(templateEngineManager);
if (!(cache instanceof ConcurrentLruCache<?, ?>)) {
throw new IllegalStateException("engineCache is not a ConcurrentLruCache");
}
return (ConcurrentLruCache<?, ?>) cache;
}
@SuppressWarnings({"rawtypes"})
private void injectGenerator(
ConcurrentLruCache engineCache,
Object templateEngineManager
) throws NoSuchFieldException, IllegalAccessException {
Field generatorField = engineCache.getClass().getDeclaredField("generator");
generatorField.setAccessible(true);
Function<Object, ISpringWebFluxTemplateEngine> newGenerator = key -> {
TemplateEngine templateEngine = generateEngine(templateEngineManager, key);
if (templateEngine == null) {
return null;
}
try {
templateEngine.addDialect(new TestProcessorDialect());
} catch (Exception ex) {
log.error("Failed to add TestDialect.", ex);
}
return (ISpringWebFluxTemplateEngine) templateEngine;
};
generatorField.set(engineCache, newGenerator);
}
private TemplateEngine generateEngine(Object templateEngineManager, Object key) {
try {
Method method = templateEngineManager.getClass()
.getDeclaredMethod("templateEngineGenerator", key.getClass());
method.setAccessible(true);
return (TemplateEngine) method.invoke(templateEngineManager, key);
} catch (Exception e) {
log.error("Failed to generate TemplateEngine.", e);
return null;
}
}
}
When the plugin starts, it attempts to install the hook. The custom IProcessorDialect is then triggered the first time a page is accessed.

Comments