From 07077f7d0c2ba0ad564c33b149dffeab693a2b37 Mon Sep 17 00:00:00 2001 From: John Niang Date: Thu, 19 Sep 2024 10:56:53 +0800 Subject: [PATCH] Provide ElementTagProcessor to handle element tag in plugin (#6670) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #### What type of PR is this? /kind feature /area plugin #### What this PR does / why we need it: This PR provides an interface ElementTagProcessor to make plugin handle element tag easily. e.g.: ```java public class ImgTagProcessor implements ElementTagPostProcessor { @Override public Mono process(ITemplateContext context, IProcessableElementTag tag, IElementTagStructureHandler structureHandler) { var elementName = tag.getElementDefinition().getElementName(); if (!Objects.equals("img", elementName.getElementName())) { return Mono.empty(); } var srcAttr = tag.getAttribute("src"); if (srcAttr == null) { return Mono.empty(); } var newSrc = srcAttr.getValue(); // TODO rewrite src structureHandler.setAttribute("src", newSrc); return Mono.empty(); } } ``` After PR merged, plugins https://github.com/webp-sh/halo-plugin-webp-cloud and https://github.com/guqing/plugin-cloudinary can be refined with new method. #### Does this PR introduce a user-facing change? ```release-note 支持在插件中操作渲染结果 ``` --- .../extensionpoint/ExtensionGetter.java | 11 ++ .../dialect/ElementTagPostProcessor.java | 41 +++++ .../DefaultExtensionGetter.java | 13 ++ .../dialect/HaloPostTemplateHandler.java | 69 ++++++++ .../theme/dialect/HaloProcessorDialect.java | 19 ++- .../DefaultExtensionGetterTest.java | 18 +++ .../ReactiveFinderExpressionParserTests.java | 11 +- .../CommentElementTagProcessorTest.java | 7 + .../dialect/HaloPostTemplateHandlerTest.java | 153 ++++++++++++++++++ 9 files changed, 339 insertions(+), 3 deletions(-) create mode 100644 api/src/main/java/run/halo/app/theme/dialect/ElementTagPostProcessor.java create mode 100644 application/src/main/java/run/halo/app/theme/dialect/HaloPostTemplateHandler.java create mode 100644 application/src/test/java/run/halo/app/theme/dialect/HaloPostTemplateHandlerTest.java diff --git a/api/src/main/java/run/halo/app/plugin/extensionpoint/ExtensionGetter.java b/api/src/main/java/run/halo/app/plugin/extensionpoint/ExtensionGetter.java index 58615ec72..964bf1a78 100644 --- a/api/src/main/java/run/halo/app/plugin/extensionpoint/ExtensionGetter.java +++ b/api/src/main/java/run/halo/app/plugin/extensionpoint/ExtensionGetter.java @@ -1,5 +1,6 @@ package run.halo.app.plugin.extensionpoint; +import java.util.List; import org.pf4j.ExtensionPoint; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -34,4 +35,14 @@ public interface ExtensionGetter { * @return a bunch of extension points. */ Flux getExtensions(Class extensionPointClass); + + /** + * Get all extensions according to extension point class. + * + * @param extensionPointClass extension point class + * @param type of extension point + * @return a bunch of extension points. + */ + List getExtensionList(Class extensionPointClass); + } diff --git a/api/src/main/java/run/halo/app/theme/dialect/ElementTagPostProcessor.java b/api/src/main/java/run/halo/app/theme/dialect/ElementTagPostProcessor.java new file mode 100644 index 000000000..77a295faa --- /dev/null +++ b/api/src/main/java/run/halo/app/theme/dialect/ElementTagPostProcessor.java @@ -0,0 +1,41 @@ +package run.halo.app.theme.dialect; + +import org.pf4j.ExtensionPoint; +import org.thymeleaf.context.ITemplateContext; +import org.thymeleaf.model.IProcessableElementTag; +import reactor.core.publisher.Mono; + +/** + * An extension point for post-processing element tag. + * + * @author johnniang + * @since 2.20.0 + */ +public interface ElementTagPostProcessor extends ExtensionPoint { + + /** + *

+ * Execute the processor. + *

+ *

+ * The {@link IProcessableElementTag} object argument is immutable, so all modifications to + * this object or any + * instructions to be given to the engine should be done through the specified + * {@link org.thymeleaf.model.IModelFactory} model factory in context. + *

+ *

+ * Don't forget to return the new tag after processing or + * {@link reactor.core.publisher.Mono#empty()} if not processable. + *

+ * + * @param context the template context. + * @param tag the event this processor is executing on. + * @return a {@link reactor.core.publisher.Mono} that will complete when processing finishes + * or empty mono if not support. + */ + Mono process( + ITemplateContext context, + final IProcessableElementTag tag + ); + +} diff --git a/application/src/main/java/run/halo/app/plugin/extensionpoint/DefaultExtensionGetter.java b/application/src/main/java/run/halo/app/plugin/extensionpoint/DefaultExtensionGetter.java index 9f5404ab3..e6c162879 100644 --- a/application/src/main/java/run/halo/app/plugin/extensionpoint/DefaultExtensionGetter.java +++ b/application/src/main/java/run/halo/app/plugin/extensionpoint/DefaultExtensionGetter.java @@ -2,7 +2,10 @@ package run.halo.app.plugin.extensionpoint; import static run.halo.app.extension.index.query.QueryFactory.equal; +import java.util.LinkedList; +import java.util.List; import java.util.Objects; +import java.util.Optional; import lombok.RequiredArgsConstructor; import org.pf4j.ExtensionPoint; import org.pf4j.PluginManager; @@ -41,6 +44,16 @@ public class DefaultExtensionGetter implements ExtensionGetter { .sort(new AnnotationAwareOrderComparator()); } + @Override + public List getExtensionList(Class extensionPoint) { + var extensions = new LinkedList(); + Optional.ofNullable(pluginManager.getExtensions(extensionPoint)) + .ifPresent(extensions::addAll); + extensions.addAll(beanFactory.getBeanProvider(extensionPoint).orderedStream().toList()); + extensions.sort(new AnnotationAwareOrderComparator()); + return extensions; + } + @Override public Mono getEnabledExtension(Class extensionPoint) { return getEnabledExtensions(extensionPoint).next(); diff --git a/application/src/main/java/run/halo/app/theme/dialect/HaloPostTemplateHandler.java b/application/src/main/java/run/halo/app/theme/dialect/HaloPostTemplateHandler.java new file mode 100644 index 000000000..1eebd67fe --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/dialect/HaloPostTemplateHandler.java @@ -0,0 +1,69 @@ +package run.halo.app.theme.dialect; + +import static org.thymeleaf.spring6.context.SpringContextUtils.getApplicationContext; + +import java.time.Duration; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import org.springframework.lang.NonNull; +import org.springframework.util.CollectionUtils; +import org.thymeleaf.context.ITemplateContext; +import org.thymeleaf.engine.AbstractTemplateHandler; +import org.thymeleaf.model.IOpenElementTag; +import org.thymeleaf.model.IProcessableElementTag; +import org.thymeleaf.model.IStandaloneElementTag; +import reactor.core.publisher.Mono; +import run.halo.app.plugin.extensionpoint.ExtensionGetter; + +/** + * Template post-handler. + * + * @author johnniang + * @since 2.20.0 + */ +public class HaloPostTemplateHandler extends AbstractTemplateHandler { + + private List postProcessors = List.of(); + + @Override + public void setContext(ITemplateContext context) { + super.setContext(context); + this.postProcessors = Optional.ofNullable(getApplicationContext(context)) + .map(appContext -> appContext.getBeanProvider(ExtensionGetter.class).getIfUnique()) + .map(extensionGetter -> extensionGetter.getExtensionList(ElementTagPostProcessor.class)) + .orElseGet(List::of); + } + + @Override + public void handleStandaloneElement(IStandaloneElementTag standaloneElementTag) { + var processedTag = handleElementTag(standaloneElementTag); + super.handleStandaloneElement((IStandaloneElementTag) processedTag); + } + + @Override + public void handleOpenElement(IOpenElementTag openElementTag) { + var processedTag = handleElementTag(openElementTag); + super.handleOpenElement((IOpenElementTag) processedTag); + } + + @NonNull + private IProcessableElementTag handleElementTag( + @NonNull IProcessableElementTag processableElementTag + ) { + IProcessableElementTag processedTag = processableElementTag; + if (!CollectionUtils.isEmpty(postProcessors)) { + var tagProcessorChain = Mono.just(processableElementTag); + var context = getContext(); + for (ElementTagPostProcessor elementTagPostProcessor : postProcessors) { + tagProcessorChain = tagProcessorChain.flatMap( + tag -> elementTagPostProcessor.process(context, tag).defaultIfEmpty(tag) + ); + } + processedTag = + Objects.requireNonNull(tagProcessorChain.defaultIfEmpty(processableElementTag) + .block(Duration.ofMinutes(1))); + } + return processedTag; + } +} diff --git a/application/src/main/java/run/halo/app/theme/dialect/HaloProcessorDialect.java b/application/src/main/java/run/halo/app/theme/dialect/HaloProcessorDialect.java index 6d61b3c48..a88296531 100644 --- a/application/src/main/java/run/halo/app/theme/dialect/HaloProcessorDialect.java +++ b/application/src/main/java/run/halo/app/theme/dialect/HaloProcessorDialect.java @@ -1,10 +1,15 @@ package run.halo.app.theme.dialect; +import static org.thymeleaf.templatemode.TemplateMode.HTML; + import java.util.HashSet; import java.util.Set; import org.thymeleaf.dialect.AbstractProcessorDialect; import org.thymeleaf.dialect.IExpressionObjectDialect; +import org.thymeleaf.dialect.IPostProcessorDialect; import org.thymeleaf.expression.IExpressionObjectFactory; +import org.thymeleaf.postprocessor.IPostProcessor; +import org.thymeleaf.postprocessor.PostProcessor; import org.thymeleaf.processor.IProcessor; import org.thymeleaf.standard.StandardDialect; @@ -14,8 +19,8 @@ import org.thymeleaf.standard.StandardDialect; * @author guqing * @since 2.0.0 */ -public class HaloProcessorDialect extends AbstractProcessorDialect implements - IExpressionObjectDialect { +public class HaloProcessorDialect extends AbstractProcessorDialect + implements IExpressionObjectDialect, IPostProcessorDialect { private static final String DIALECT_NAME = "haloThemeProcessorDialect"; private static final IExpressionObjectFactory HALO_EXPRESSION_OBJECTS_FACTORY = @@ -43,4 +48,14 @@ public class HaloProcessorDialect extends AbstractProcessorDialect implements public IExpressionObjectFactory getExpressionObjectFactory() { return HALO_EXPRESSION_OBJECTS_FACTORY; } + + @Override + public int getDialectPostProcessorPrecedence() { + return Integer.MAX_VALUE; + } + + @Override + public Set getPostProcessors() { + return Set.of(new PostProcessor(HTML, HaloPostTemplateHandler.class, Integer.MAX_VALUE)); + } } diff --git a/application/src/test/java/run/halo/app/plugin/extensionpoint/DefaultExtensionGetterTest.java b/application/src/test/java/run/halo/app/plugin/extensionpoint/DefaultExtensionGetterTest.java index bab1b6e7d..b876aa10c 100644 --- a/application/src/test/java/run/halo/app/plugin/extensionpoint/DefaultExtensionGetterTest.java +++ b/application/src/test/java/run/halo/app/plugin/extensionpoint/DefaultExtensionGetterTest.java @@ -1,5 +1,6 @@ package run.halo.app.plugin.extensionpoint; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.same; import static org.mockito.Mockito.mock; @@ -44,6 +45,9 @@ class DefaultExtensionGetterTest { @Mock BeanFactory beanFactory; + @Mock + ObjectProvider extensionPointObjectProvider; + @InjectMocks DefaultExtensionGetter getter; @@ -209,6 +213,20 @@ class DefaultExtensionGetterTest { .verifyComplete(); } + @Test + void shouldGetExtensionsFromPluginManagerAndApplicationContext() { + var extensionFromPlugin = new FakeExtensionPointDefaultImpl(); + var extensionFromAppContext = new FakeExtensionPointImpl(); + when(pluginManager.getExtensions(FakeExtensionPoint.class)) + .thenReturn(List.of(extensionFromPlugin)); + when(beanFactory.getBeanProvider(FakeExtensionPoint.class)) + .thenReturn(extensionPointObjectProvider); + when(extensionPointObjectProvider.orderedStream()) + .thenReturn(Stream.of(extensionFromAppContext)); + var extensions = getter.getExtensionList(FakeExtensionPoint.class); + assertEquals(List.of(extensionFromAppContext, extensionFromPlugin), extensions); + } + interface FakeExtensionPoint extends ExtensionPoint { } diff --git a/application/src/test/java/run/halo/app/theme/ReactiveFinderExpressionParserTests.java b/application/src/test/java/run/halo/app/theme/ReactiveFinderExpressionParserTests.java index cd6f5bd02..346aa9945 100644 --- a/application/src/test/java/run/halo/app/theme/ReactiveFinderExpressionParserTests.java +++ b/application/src/test/java/run/halo/app/theme/ReactiveFinderExpressionParserTests.java @@ -3,6 +3,7 @@ package run.halo.app.theme; 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.when; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ArrayNode; @@ -14,6 +15,7 @@ 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; @@ -29,6 +31,7 @@ import reactor.core.publisher.Mono; import run.halo.app.infra.SystemConfigurableEnvironmentFetcher; import run.halo.app.infra.SystemSetting; import run.halo.app.infra.utils.JsonUtils; +import run.halo.app.plugin.extensionpoint.ExtensionGetter; import run.halo.app.theme.dialect.HaloProcessorDialect; /** @@ -44,6 +47,9 @@ public class ReactiveFinderExpressionParserTests { @Mock private ApplicationContext applicationContext; + @Mock + private ObjectProvider extensionGetterProvider; + @Mock private SystemConfigurableEnvironmentFetcher environmentFetcher; @@ -62,6 +68,9 @@ public class ReactiveFinderExpressionParserTests { templateEngine.addTemplateResolver(new TestTemplateResolver()); lenient().when(applicationContext.getBean(eq(SystemConfigurableEnvironmentFetcher.class))) .thenReturn(environmentFetcher); + when(applicationContext.getBeanProvider(ExtensionGetter.class)) + .thenReturn(extensionGetterProvider); + when(extensionGetterProvider.getIfUnique()).thenReturn(null); lenient().when(environmentFetcher.fetchComment()) .thenReturn(Mono.just(new SystemSetting.Comment())); } @@ -155,7 +164,7 @@ public class ReactiveFinderExpressionParserTests { var mapMono = /*[[${target.mapMono.foo}]]*/; var arrayNodeMono = /*[[${target.arrayNodeMono.get(0).foo}]]*/; - """); + """); } } diff --git a/application/src/test/java/run/halo/app/theme/dialect/CommentElementTagProcessorTest.java b/application/src/test/java/run/halo/app/theme/dialect/CommentElementTagProcessorTest.java index 2a0726b2e..a348af54d 100644 --- a/application/src/test/java/run/halo/app/theme/dialect/CommentElementTagProcessorTest.java +++ b/application/src/test/java/run/halo/app/theme/dialect/CommentElementTagProcessorTest.java @@ -13,6 +13,7 @@ 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; @@ -48,6 +49,9 @@ class CommentElementTagProcessorTest { @Mock private ExtensionGetter extensionGetter; + @Mock + private ObjectProvider extensionGetterProvider; + @Mock private SystemConfigurableEnvironmentFetcher environmentFetcher; @@ -61,6 +65,9 @@ class CommentElementTagProcessorTest { templateEngine.addTemplateResolver(new TestTemplateResolver()); lenient().when(applicationContext.getBean(eq(ExtensionGetter.class))) .thenReturn(extensionGetter); + when(applicationContext.getBeanProvider(ExtensionGetter.class)) + .thenReturn(extensionGetterProvider); + when(extensionGetterProvider.getIfUnique()).thenReturn(null); } @Test diff --git a/application/src/test/java/run/halo/app/theme/dialect/HaloPostTemplateHandlerTest.java b/application/src/test/java/run/halo/app/theme/dialect/HaloPostTemplateHandlerTest.java new file mode 100644 index 000000000..20c32f90b --- /dev/null +++ b/application/src/test/java/run/halo/app/theme/dialect/HaloPostTemplateHandlerTest.java @@ -0,0 +1,153 @@ +package run.halo.app.theme.dialect; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.thymeleaf.spring6.expression.ThymeleafEvaluationContext.THYMELEAF_EVALUATION_CONTEXT_CONTEXT_VARIABLE_NAME; + +import java.util.List; +import java.util.stream.Stream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.context.ApplicationContext; +import org.thymeleaf.context.ITemplateContext; +import org.thymeleaf.engine.ITemplateHandler; +import org.thymeleaf.model.IOpenElementTag; +import org.thymeleaf.model.IStandaloneElementTag; +import org.thymeleaf.spring6.expression.ThymeleafEvaluationContext; +import reactor.core.publisher.Mono; +import run.halo.app.plugin.extensionpoint.ExtensionGetter; + +@ExtendWith(MockitoExtension.class) +class HaloPostTemplateHandlerTest { + + HaloPostTemplateHandler postHandler; + + @Mock + ITemplateContext templateContext; + + @Mock + ITemplateHandler next; + + @Mock + ApplicationContext applicationContext; + + @Mock + IStandaloneElementTag standaloneElementTag; + + @Mock + IOpenElementTag openElementTag; + + @Mock + ObjectProvider extensionGetterProvider; + + @Mock + ExtensionGetter extensionGetter; + + + @BeforeEach + void setUp() { + postHandler = new HaloPostTemplateHandler(); + var evaluationContext = mock(ThymeleafEvaluationContext.class); + when(evaluationContext.getApplicationContext()).thenReturn(applicationContext); + when(templateContext.getVariable(THYMELEAF_EVALUATION_CONTEXT_CONTEXT_VARIABLE_NAME)) + .thenReturn(evaluationContext); + when(applicationContext.getBeanProvider(ExtensionGetter.class)) + .thenReturn(extensionGetterProvider); + when(extensionGetterProvider.getIfUnique()).thenReturn(extensionGetter); + } + + @ParameterizedTest + @MethodSource("provideEmptyElementTagProcessors") + void shouldHandleStandaloneElementIfNoElementTagProcessors( + List processors + ) { + when(extensionGetter.getExtensionList(ElementTagPostProcessor.class)) + .thenReturn(processors); + + postHandler.setContext(templateContext); + postHandler.setNext(next); + postHandler.handleStandaloneElement(standaloneElementTag); + verify(next).handleStandaloneElement(standaloneElementTag); + } + + @Test + void shouldHandleStandaloneElementIfOneElementTagProcessorProvided() { + var processor = mock(ElementTagPostProcessor.class); + var newTag = mock(IStandaloneElementTag.class); + when(processor.process(templateContext, standaloneElementTag)) + .thenReturn(Mono.just(newTag)); + when(extensionGetter.getExtensionList(ElementTagPostProcessor.class)) + .thenReturn(List.of(processor)); + + postHandler.setContext(templateContext); + postHandler.setNext(next); + postHandler.handleStandaloneElement(standaloneElementTag); + verify(next).handleStandaloneElement(newTag); + } + + @Test + void shouldHandleStandaloneElementIfTagTypeChanged() { + var processor = mock(ElementTagPostProcessor.class); + var newTag = mock(IStandaloneElementTag.class); + when(processor.process(templateContext, standaloneElementTag)) + .thenReturn(Mono.just(newTag)); + when(extensionGetter.getExtensionList(ElementTagPostProcessor.class)) + .thenReturn(List.of(processor)); + + postHandler.setContext(templateContext); + postHandler.setNext(next); + postHandler.handleStandaloneElement(standaloneElementTag); + verify(next).handleStandaloneElement(newTag); + } + + @Test + void shouldHandleStandaloneElementIfMoreElementTagProcessorsProvided() { + var processor1 = mock(ElementTagPostProcessor.class); + var processor2 = mock(ElementTagPostProcessor.class); + var newTag1 = mock(IStandaloneElementTag.class); + var newTag2 = mock(IStandaloneElementTag.class); + when(processor1.process(templateContext, standaloneElementTag)) + .thenReturn(Mono.just(newTag1)); + when(processor2.process(templateContext, newTag1)) + .thenReturn(Mono.just(newTag2)); + when(extensionGetter.getExtensionList(ElementTagPostProcessor.class)) + .thenReturn(List.of(processor1, processor2)); + + postHandler.setContext(templateContext); + postHandler.setNext(next); + postHandler.handleStandaloneElement(standaloneElementTag); + verify(next).handleStandaloneElement(newTag2); + } + + @Test + void shouldNotHandleIfProcessedTagTypeChanged() { + var processor = mock(ElementTagPostProcessor.class); + var newTag = mock(IOpenElementTag.class); + when(processor.process(templateContext, standaloneElementTag)) + .thenReturn(Mono.just(newTag)); + when(extensionGetter.getExtensionList(ElementTagPostProcessor.class)) + .thenReturn(List.of(processor)); + + postHandler.setContext(templateContext); + postHandler.setNext(next); + assertThrows(ClassCastException.class, + () -> postHandler.handleStandaloneElement(standaloneElementTag) + ); + } + + static Stream> provideEmptyElementTagProcessors() { + return Stream.of( + null, + List.of() + ); + } + +} \ No newline at end of file