mirror of https://github.com/halo-dev/halo
feat: add TemplateFooterProcessor extension point for extending footer tag content in theme (#6191)
#### What type of PR is this? /kind feature /area core /milestone 2.17.x #### What this PR does / why we need it: 提供对模板中 halo footer 标签内容的扩展点以支持扩展页脚内容 #### Which issue(s) this PR fixes: Fixes #6189 #### Does this PR introduce a user-facing change? ```release-note 提供对模板中 halo footer 标签内容的扩展点以支持扩展页脚内容 ```pull/6279/head
parent
1f4bf8ea47
commit
f5ebd9fe43
|
@ -0,0 +1,20 @@
|
||||||
|
package run.halo.app.theme.dialect;
|
||||||
|
|
||||||
|
import org.pf4j.ExtensionPoint;
|
||||||
|
import org.thymeleaf.context.ITemplateContext;
|
||||||
|
import org.thymeleaf.model.IModel;
|
||||||
|
import org.thymeleaf.model.IProcessableElementTag;
|
||||||
|
import org.thymeleaf.processor.element.IElementTagStructureHandler;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Theme template <code>footer</code> tag snippet injection processor.
|
||||||
|
*
|
||||||
|
* @author guqing
|
||||||
|
* @since 2.17.0
|
||||||
|
*/
|
||||||
|
public interface TemplateFooterProcessor extends ExtensionPoint {
|
||||||
|
|
||||||
|
Mono<Void> process(ITemplateContext context, IProcessableElementTag tag,
|
||||||
|
IElementTagStructureHandler structureHandler, IModel model);
|
||||||
|
}
|
|
@ -1,14 +1,19 @@
|
||||||
package run.halo.app.theme.dialect;
|
package run.halo.app.theme.dialect;
|
||||||
|
|
||||||
|
import static org.thymeleaf.spring6.context.SpringContextUtils.getApplicationContext;
|
||||||
|
|
||||||
import org.springframework.context.ApplicationContext;
|
import org.springframework.context.ApplicationContext;
|
||||||
import org.thymeleaf.context.ITemplateContext;
|
import org.thymeleaf.context.ITemplateContext;
|
||||||
|
import org.thymeleaf.model.IModel;
|
||||||
import org.thymeleaf.model.IProcessableElementTag;
|
import org.thymeleaf.model.IProcessableElementTag;
|
||||||
import org.thymeleaf.processor.element.AbstractElementTagProcessor;
|
import org.thymeleaf.processor.element.AbstractElementTagProcessor;
|
||||||
import org.thymeleaf.processor.element.IElementTagStructureHandler;
|
import org.thymeleaf.processor.element.IElementTagStructureHandler;
|
||||||
import org.thymeleaf.spring6.context.SpringContextUtils;
|
import org.thymeleaf.spring6.context.SpringContextUtils;
|
||||||
import org.thymeleaf.templatemode.TemplateMode;
|
import org.thymeleaf.templatemode.TemplateMode;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
|
import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
|
||||||
import run.halo.app.infra.SystemSetting;
|
import run.halo.app.infra.SystemSetting;
|
||||||
|
import run.halo.app.plugin.extensionpoint.ExtensionGetter;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* <p>Footer element tag processor.</p>
|
* <p>Footer element tag processor.</p>
|
||||||
|
@ -42,12 +47,23 @@ public class TemplateFooterElementTagProcessor extends AbstractElementTagProcess
|
||||||
@Override
|
@Override
|
||||||
protected void doProcess(ITemplateContext context, IProcessableElementTag tag,
|
protected void doProcess(ITemplateContext context, IProcessableElementTag tag,
|
||||||
IElementTagStructureHandler structureHandler) {
|
IElementTagStructureHandler structureHandler) {
|
||||||
|
|
||||||
|
IModel modelToInsert = context.getModelFactory().createModel();
|
||||||
/*
|
/*
|
||||||
* Obtain the Spring application context.
|
* Obtain the Spring application context.
|
||||||
*/
|
*/
|
||||||
final ApplicationContext appCtx = SpringContextUtils.getApplicationContext(context);
|
final ApplicationContext appCtx = SpringContextUtils.getApplicationContext(context);
|
||||||
|
|
||||||
String globalFooterText = getGlobalFooterText(appCtx);
|
String globalFooterText = getGlobalFooterText(appCtx);
|
||||||
structureHandler.replaceWith(globalFooterText, false);
|
modelToInsert.add(context.getModelFactory().createText(globalFooterText));
|
||||||
|
|
||||||
|
getTemplateFooterProcessors(context)
|
||||||
|
.concatMap(processor -> processor.process(context, tag,
|
||||||
|
structureHandler, modelToInsert)
|
||||||
|
)
|
||||||
|
.then()
|
||||||
|
.block();
|
||||||
|
structureHandler.replaceWith(modelToInsert, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private String getGlobalFooterText(ApplicationContext appCtx) {
|
private String getGlobalFooterText(ApplicationContext appCtx) {
|
||||||
|
@ -57,4 +73,13 @@ public class TemplateFooterElementTagProcessor extends AbstractElementTagProcess
|
||||||
.map(SystemSetting.CodeInjection::getFooter)
|
.map(SystemSetting.CodeInjection::getFooter)
|
||||||
.block();
|
.block();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Flux<TemplateFooterProcessor> getTemplateFooterProcessors(ITemplateContext context) {
|
||||||
|
var extensionGetter = getApplicationContext(context).getBeanProvider(ExtensionGetter.class)
|
||||||
|
.getIfUnique();
|
||||||
|
if (extensionGetter == null) {
|
||||||
|
return Flux.empty();
|
||||||
|
}
|
||||||
|
return extensionGetter.getExtensions(TemplateFooterProcessor.class);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -76,3 +76,15 @@ spec:
|
||||||
displayName: "搜索引擎"
|
displayName: "搜索引擎"
|
||||||
type: SINGLETON
|
type: SINGLETON
|
||||||
description: "扩展内容搜索引擎"
|
description: "扩展内容搜索引擎"
|
||||||
|
|
||||||
|
---
|
||||||
|
apiVersion: plugin.halo.run/v1alpha1
|
||||||
|
kind: ExtensionPointDefinition
|
||||||
|
metadata:
|
||||||
|
name: template-footer-processor
|
||||||
|
spec:
|
||||||
|
className: run.halo.app.theme.dialect.TemplateFooterProcessor
|
||||||
|
displayName: 页脚标签内容处理器
|
||||||
|
type: MULTI_INSTANCE
|
||||||
|
description: "提供用于扩展 <halo:footer/> 标签内容的扩展方式。"
|
||||||
|
|
|
@ -125,6 +125,9 @@ class HaloProcessorDialectTest {
|
||||||
when(fetcher.fetch(eq(SystemSetting.Basic.GROUP),
|
when(fetcher.fetch(eq(SystemSetting.Basic.GROUP),
|
||||||
eq(SystemSetting.Basic.class))).thenReturn(Mono.just(basic));
|
eq(SystemSetting.Basic.class))).thenReturn(Mono.just(basic));
|
||||||
|
|
||||||
|
when(extensionGetter.getExtensions(TemplateFooterProcessor.class))
|
||||||
|
.thenReturn(Flux.empty());
|
||||||
|
|
||||||
Context context = getContext();
|
Context context = getContext();
|
||||||
|
|
||||||
String result = templateEngine.process("index", context);
|
String result = templateEngine.process("index", context);
|
||||||
|
@ -172,6 +175,9 @@ class HaloProcessorDialectTest {
|
||||||
when(fetcher.fetch(eq(SystemSetting.Basic.GROUP),
|
when(fetcher.fetch(eq(SystemSetting.Basic.GROUP),
|
||||||
eq(SystemSetting.Basic.class))).thenReturn(Mono.just(basic));
|
eq(SystemSetting.Basic.class))).thenReturn(Mono.just(basic));
|
||||||
|
|
||||||
|
when(extensionGetter.getExtensions(TemplateFooterProcessor.class))
|
||||||
|
.thenReturn(Flux.empty());
|
||||||
|
|
||||||
String result = templateEngine.process("post", context);
|
String result = templateEngine.process("post", context);
|
||||||
assertThat(result).isEqualTo("""
|
assertThat(result).isEqualTo("""
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
|
|
|
@ -0,0 +1,136 @@
|
||||||
|
package run.halo.app.theme.dialect;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.lenient;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.springframework.beans.factory.ObjectProvider;
|
||||||
|
import org.springframework.context.ApplicationContext;
|
||||||
|
import org.thymeleaf.IEngineConfiguration;
|
||||||
|
import org.thymeleaf.TemplateEngine;
|
||||||
|
import org.thymeleaf.context.Context;
|
||||||
|
import org.thymeleaf.context.ITemplateContext;
|
||||||
|
import org.thymeleaf.model.IModel;
|
||||||
|
import org.thymeleaf.model.IProcessableElementTag;
|
||||||
|
import org.thymeleaf.processor.IProcessor;
|
||||||
|
import org.thymeleaf.processor.element.IElementTagStructureHandler;
|
||||||
|
import org.thymeleaf.spring6.dialect.SpringStandardDialect;
|
||||||
|
import org.thymeleaf.spring6.expression.ThymeleafEvaluationContext;
|
||||||
|
import org.thymeleaf.templateresolver.StringTemplateResolver;
|
||||||
|
import org.thymeleaf.templateresource.ITemplateResource;
|
||||||
|
import org.thymeleaf.templateresource.StringTemplateResource;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
|
||||||
|
import run.halo.app.infra.SystemSetting;
|
||||||
|
import run.halo.app.plugin.extensionpoint.ExtensionGetter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for {@link TemplateFooterElementTagProcessor}.
|
||||||
|
*
|
||||||
|
* @author guqing
|
||||||
|
* @since 2.17.0
|
||||||
|
*/
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class TemplateFooterElementTagProcessorTest {
|
||||||
|
@Mock
|
||||||
|
private ApplicationContext applicationContext;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
ExtensionGetter extensionGetter;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private SystemConfigurableEnvironmentFetcher fetcher;
|
||||||
|
|
||||||
|
private TemplateEngine templateEngine;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
HaloProcessorDialect haloProcessorDialect = new MockHaloProcessorDialect();
|
||||||
|
templateEngine = new TemplateEngine();
|
||||||
|
templateEngine.setDialects(Set.of(haloProcessorDialect, new SpringStandardDialect()));
|
||||||
|
templateEngine.addTemplateResolver(new MockTemplateResolver());
|
||||||
|
|
||||||
|
SystemSetting.CodeInjection codeInjection = new SystemSetting.CodeInjection();
|
||||||
|
codeInjection.setFooter(
|
||||||
|
"<p>Powered by <a href=\"https://www.halo.run\" target=\"_blank\">Halo</a></p>");
|
||||||
|
lenient().when(fetcher.fetch(eq(SystemSetting.CodeInjection.GROUP),
|
||||||
|
eq(SystemSetting.CodeInjection.class))).thenReturn(Mono.just(codeInjection));
|
||||||
|
|
||||||
|
lenient().when(applicationContext.getBeanProvider(ExtensionGetter.class))
|
||||||
|
.thenAnswer(invocation -> {
|
||||||
|
var objectProvider = mock(ObjectProvider.class);
|
||||||
|
when(objectProvider.getIfUnique()).thenReturn(extensionGetter);
|
||||||
|
return objectProvider;
|
||||||
|
});
|
||||||
|
lenient().when(applicationContext.getBean(eq(SystemConfigurableEnvironmentFetcher.class)))
|
||||||
|
.thenReturn(fetcher);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void footerProcessorTest() {
|
||||||
|
when(extensionGetter.getExtensions(TemplateFooterProcessor.class))
|
||||||
|
.thenReturn(Flux.just(new FakeFooterCodeInjection()));
|
||||||
|
|
||||||
|
String result = templateEngine.process("fake-template", getContext());
|
||||||
|
// footer injected code is not processable
|
||||||
|
assertThat(result).isEqualToIgnoringWhitespace("""
|
||||||
|
<p>Powered by <a href="https://www.halo.run" target="_blank">Halo</a></p>
|
||||||
|
<div>© 2024 guqing's blog</div>
|
||||||
|
<div th:text="${footerText}"></div>
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
|
||||||
|
private Context getContext() {
|
||||||
|
Context context = new Context();
|
||||||
|
context.setVariable(
|
||||||
|
ThymeleafEvaluationContext.THYMELEAF_EVALUATION_CONTEXT_CONTEXT_VARIABLE_NAME,
|
||||||
|
new ThymeleafEvaluationContext(applicationContext, null));
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
static class MockTemplateResolver extends StringTemplateResolver {
|
||||||
|
@Override
|
||||||
|
protected ITemplateResource computeTemplateResource(IEngineConfiguration configuration,
|
||||||
|
String ownerTemplate, String template,
|
||||||
|
Map<String, Object> templateResolutionAttributes) {
|
||||||
|
return new StringTemplateResource("""
|
||||||
|
<halo:footer />
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static class MockHaloProcessorDialect extends HaloProcessorDialect {
|
||||||
|
@Override
|
||||||
|
public Set<IProcessor> getProcessors(String dialectPrefix) {
|
||||||
|
var processors = new HashSet<IProcessor>();
|
||||||
|
processors.add(new TemplateFooterElementTagProcessor(dialectPrefix));
|
||||||
|
return processors;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static class FakeFooterCodeInjection implements TemplateFooterProcessor {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<Void> process(ITemplateContext context, IProcessableElementTag tag,
|
||||||
|
IElementTagStructureHandler structureHandler, IModel model) {
|
||||||
|
var factory = context.getModelFactory();
|
||||||
|
// regular footer text
|
||||||
|
var copyRight = factory.createText("<div>© 2024 guqing's blog</div>");
|
||||||
|
model.add(copyRight);
|
||||||
|
// variable footer text
|
||||||
|
model.add(factory.createText("<div th:text=\"${footerText}\"></div>"));
|
||||||
|
return Mono.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue