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
guqing 2024-07-01 17:49:17 +08:00 committed by GitHub
parent 1f4bf8ea47
commit f5ebd9fe43
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 200 additions and 1 deletions

View File

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

View File

@ -1,14 +1,19 @@
package run.halo.app.theme.dialect;
import static org.thymeleaf.spring6.context.SpringContextUtils.getApplicationContext;
import org.springframework.context.ApplicationContext;
import org.thymeleaf.context.ITemplateContext;
import org.thymeleaf.model.IModel;
import org.thymeleaf.model.IProcessableElementTag;
import org.thymeleaf.processor.element.AbstractElementTagProcessor;
import org.thymeleaf.processor.element.IElementTagStructureHandler;
import org.thymeleaf.spring6.context.SpringContextUtils;
import org.thymeleaf.templatemode.TemplateMode;
import reactor.core.publisher.Flux;
import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
import run.halo.app.infra.SystemSetting;
import run.halo.app.plugin.extensionpoint.ExtensionGetter;
/**
* <p>Footer element tag processor.</p>
@ -42,12 +47,23 @@ public class TemplateFooterElementTagProcessor extends AbstractElementTagProcess
@Override
protected void doProcess(ITemplateContext context, IProcessableElementTag tag,
IElementTagStructureHandler structureHandler) {
IModel modelToInsert = context.getModelFactory().createModel();
/*
* Obtain the Spring application context.
*/
final ApplicationContext appCtx = SpringContextUtils.getApplicationContext(context);
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) {
@ -57,4 +73,13 @@ public class TemplateFooterElementTagProcessor extends AbstractElementTagProcess
.map(SystemSetting.CodeInjection::getFooter)
.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);
}
}

View File

@ -76,3 +76,15 @@ spec:
displayName: "搜索引擎"
type: SINGLETON
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/> 标签内容的扩展方式。"

View File

@ -125,6 +125,9 @@ class HaloProcessorDialectTest {
when(fetcher.fetch(eq(SystemSetting.Basic.GROUP),
eq(SystemSetting.Basic.class))).thenReturn(Mono.just(basic));
when(extensionGetter.getExtensions(TemplateFooterProcessor.class))
.thenReturn(Flux.empty());
Context context = getContext();
String result = templateEngine.process("index", context);
@ -172,6 +175,9 @@ class HaloProcessorDialectTest {
when(fetcher.fetch(eq(SystemSetting.Basic.GROUP),
eq(SystemSetting.Basic.class))).thenReturn(Mono.just(basic));
when(extensionGetter.getExtensions(TemplateFooterProcessor.class))
.thenReturn(Flux.empty());
String result = templateEngine.process("post", context);
assertThat(result).isEqualTo("""
<!DOCTYPE html>

View File

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