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 00000000000..88d4828d3f9 --- /dev/null +++ b/api/src/main/java/run/halo/app/theme/dialect/ElementTagPostProcessor.java @@ -0,0 +1,40 @@ +package run.halo.app.theme.dialect; + +import org.pf4j.ExtensionPoint; +import org.thymeleaf.context.ITemplateContext; +import org.thymeleaf.model.IProcessableElementTag; +import org.thymeleaf.processor.element.IElementTagStructureHandler; +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 IElementTagStructureHandler} handler. + *

+ * + * @param context the execution context. + * @param tag the event this processor is executing on. + * @param structureHandler the handler that will centralise modifications and commands to the + * engine. + * @return a {@link Mono} that will complete when processing finishes or empty mono if + * not support. + */ + Mono process( + final ITemplateContext context, + final IProcessableElementTag tag, + final IElementTagStructureHandler structureHandler); + +} diff --git a/application/src/main/java/run/halo/app/theme/TemplateEngineManager.java b/application/src/main/java/run/halo/app/theme/TemplateEngineManager.java index 36ac57da3ba..bf99d52a237 100644 --- a/application/src/main/java/run/halo/app/theme/TemplateEngineManager.java +++ b/application/src/main/java/run/halo/app/theme/TemplateEngineManager.java @@ -19,6 +19,7 @@ import run.halo.app.infra.ExternalUrlSupplier; import run.halo.app.infra.exception.NotFoundException; import run.halo.app.plugin.HaloPluginManager; +import run.halo.app.plugin.extensionpoint.ExtensionGetter; import run.halo.app.theme.dialect.HaloProcessorDialect; import run.halo.app.theme.engine.HaloTemplateEngine; import run.halo.app.theme.engine.PluginClassloaderTemplateResolver; @@ -56,16 +57,22 @@ public class TemplateEngineManager { private final ThemeResolver themeResolver; + private final ExtensionGetter extensionGetter; + public TemplateEngineManager(ThymeleafProperties thymeleafProperties, ExternalUrlSupplier externalUrlSupplier, - HaloPluginManager haloPluginManager, ObjectProvider templateResolvers, - ObjectProvider dialects, ThemeResolver themeResolver) { + HaloPluginManager haloPluginManager, + ObjectProvider templateResolvers, + ObjectProvider dialects, + ThemeResolver themeResolver, + ExtensionGetter extensionGetter) { this.thymeleafProperties = thymeleafProperties; this.externalUrlSupplier = externalUrlSupplier; this.haloPluginManager = haloPluginManager; this.templateResolvers = templateResolvers; this.dialects = dialects; this.themeResolver = themeResolver; + this.extensionGetter = extensionGetter; engineCache = new ConcurrentLruCache<>(CACHE_SIZE_LIMIT, this::templateEngineGenerator); } @@ -134,7 +141,7 @@ public IStandardVariableExpressionEvaluator getVariableExpressionEvaluator() { return ReactiveSpelVariableExpressionEvaluator.INSTANCE; } }); - engine.addDialect(new HaloProcessorDialect()); + engine.addDialect(new HaloProcessorDialect(extensionGetter)); templateResolvers.orderedStream().forEach(engine::addTemplateResolver); diff --git a/application/src/main/java/run/halo/app/theme/dialect/ElementTagPostProcessors.java b/application/src/main/java/run/halo/app/theme/dialect/ElementTagPostProcessors.java new file mode 100644 index 00000000000..7f97d0c50ce --- /dev/null +++ b/application/src/main/java/run/halo/app/theme/dialect/ElementTagPostProcessors.java @@ -0,0 +1,56 @@ +package run.halo.app.theme.dialect; + +import java.time.Duration; +import lombok.extern.slf4j.Slf4j; +import org.thymeleaf.context.ITemplateContext; +import org.thymeleaf.model.IProcessableElementTag; +import org.thymeleaf.processor.element.AbstractElementTagProcessor; +import org.thymeleaf.processor.element.IElementTagStructureHandler; +import org.thymeleaf.templatemode.TemplateMode; +import run.halo.app.plugin.extensionpoint.ExtensionGetter; + +/** + * Element tag processors. + * + * @author johnniang + * @since 2.20.0 + */ +@Slf4j +public class ElementTagPostProcessors extends AbstractElementTagProcessor { + + private static final int PRECEDENCE = Integer.MAX_VALUE; + + private final ExtensionGetter extensionGetter; + + public ElementTagPostProcessors(ExtensionGetter extensionGetter) { + super(TemplateMode.HTML, + null, + null, + false, + null, + false, + PRECEDENCE); + this.extensionGetter = extensionGetter; + } + + @Override + protected void doProcess( + ITemplateContext context, + IProcessableElementTag tag, + IElementTagStructureHandler structureHandler + ) { + extensionGetter.getExtensions(ElementTagPostProcessor.class) + .concatMap(processor -> processor.process(context, tag, structureHandler) + .doOnSuccess(v -> { + if (log.isDebugEnabled()) { + log.debug("Processed tag [{}] with processor [{}]", + tag.getElementCompleteName(), processor.getClass().getName() + ); + } + }) + ) + .then() + .block(Duration.ofSeconds(20)); + } + +} 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 6d61b3c48ee..6d9fdff659f 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 @@ -7,6 +7,7 @@ import org.thymeleaf.expression.IExpressionObjectFactory; import org.thymeleaf.processor.IProcessor; import org.thymeleaf.standard.StandardDialect; +import run.halo.app.plugin.extensionpoint.ExtensionGetter; /** * Thymeleaf processor dialect for Halo. @@ -21,10 +22,13 @@ public class HaloProcessorDialect extends AbstractProcessorDialect implements private static final IExpressionObjectFactory HALO_EXPRESSION_OBJECTS_FACTORY = new HaloExpressionObjectFactory(); - public HaloProcessorDialect() { + private final ExtensionGetter extensionGetter; + + public HaloProcessorDialect(ExtensionGetter extensionGetter) { // We will set this dialect the same "dialect processor" precedence as // the Standard Dialect, so that processor executions can interleave. super(DIALECT_NAME, "halo", StandardDialect.PROCESSOR_PRECEDENCE); + this.extensionGetter = extensionGetter; } @Override @@ -36,6 +40,7 @@ public Set getProcessors(String dialectPrefix) { processors.add(new JsonNodePropertyAccessorBoundariesProcessor()); processors.add(new CommentElementTagProcessor(dialectPrefix)); processors.add(new CommentEnabledVariableProcessor()); + processors.add(new ElementTagPostProcessors(extensionGetter)); return processors; } 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 cd6f5bd02dd..2550bbf8394 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 @@ 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; @@ -29,6 +30,8 @@ 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.ElementTagPostProcessor; import run.halo.app.theme.dialect.HaloProcessorDialect; /** @@ -47,11 +50,15 @@ public class ReactiveFinderExpressionParserTests { @Mock private SystemConfigurableEnvironmentFetcher environmentFetcher; + @Mock + ExtensionGetter extensionGetter; + private TemplateEngine templateEngine; @BeforeEach void setUp() { - HaloProcessorDialect haloProcessorDialect = new HaloProcessorDialect(); + when(extensionGetter.getExtensions(ElementTagPostProcessor.class)).thenReturn(Flux.empty()); + HaloProcessorDialect haloProcessorDialect = new HaloProcessorDialect(extensionGetter); templateEngine = new TemplateEngine(); templateEngine.setDialects(Set.of(haloProcessorDialect, new SpringStandardDialect() { @Override 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 2a0726b2e7d..1c8d16aac74 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 @@ -55,12 +55,13 @@ class CommentElementTagProcessorTest { @BeforeEach void setUp() { - HaloProcessorDialect haloProcessorDialect = new HaloProcessorDialect(); + HaloProcessorDialect haloProcessorDialect = new HaloProcessorDialect(extensionGetter); templateEngine = new TemplateEngine(); templateEngine.setDialects(Set.of(haloProcessorDialect, new SpringStandardDialect())); templateEngine.addTemplateResolver(new TestTemplateResolver()); lenient().when(applicationContext.getBean(eq(ExtensionGetter.class))) .thenReturn(extensionGetter); + when(extensionGetter.getExtensions(ElementTagPostProcessor.class)).thenReturn(Flux.empty()); } @Test diff --git a/application/src/test/java/run/halo/app/theme/dialect/ContentTemplateHeadProcessorIntegrationTest.java b/application/src/test/java/run/halo/app/theme/dialect/ContentTemplateHeadProcessorIntegrationTest.java index 2ab16d454e1..92b51e3e815 100644 --- a/application/src/test/java/run/halo/app/theme/dialect/ContentTemplateHeadProcessorIntegrationTest.java +++ b/application/src/test/java/run/halo/app/theme/dialect/ContentTemplateHeadProcessorIntegrationTest.java @@ -74,7 +74,7 @@ class ContentTemplateHeadProcessorIntegrationTest { @BeforeEach void setUp() { - HaloProcessorDialect haloProcessorDialect = new HaloProcessorDialect(); + HaloProcessorDialect haloProcessorDialect = new HaloProcessorDialect(extensionGetter); templateEngine = new TemplateEngine(); templateEngine.setDialects(Set.of(haloProcessorDialect, new SpringStandardDialect())); templateEngine.addTemplateResolver(new TestTemplateResolver()); @@ -114,6 +114,7 @@ void setUp() { lenient().when(extensionGetter.getExtensions(TemplateHeadProcessor.class)).thenReturn( Flux.fromIterable(map.values()).sort(AnnotationAwareOrderComparator.INSTANCE) ); + when(extensionGetter.getExtensions(ElementTagPostProcessor.class)).thenReturn(Flux.empty()); lenient().when(applicationContext.getBean(eq(SystemConfigurableEnvironmentFetcher.class))) .thenReturn(fetcher); lenient().when(fetcher.fetchComment()).thenReturn(Mono.just(new SystemSetting.Comment())); diff --git a/application/src/test/java/run/halo/app/theme/dialect/ElementTagPostProcessorsTest.java b/application/src/test/java/run/halo/app/theme/dialect/ElementTagPostProcessorsTest.java new file mode 100644 index 00000000000..d58d070ec26 --- /dev/null +++ b/application/src/test/java/run/halo/app/theme/dialect/ElementTagPostProcessorsTest.java @@ -0,0 +1,81 @@ +package run.halo.app.theme.dialect; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.thymeleaf.exceptions.TemplateProcessingException; +import org.thymeleaf.model.IProcessableElementTag; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import run.halo.app.plugin.extensionpoint.ExtensionGetter; + +@ExtendWith(MockitoExtension.class) +class ElementTagPostProcessorsTest { + + @Mock + ExtensionGetter extensionGetter; + + @InjectMocks + ElementTagPostProcessors elementTagPostProcessors; + + @Test + void shouldDoNothingIfNoProcessorsFound() { + when(extensionGetter.getExtensions(ElementTagPostProcessor.class)).thenReturn(Flux.empty()); + elementTagPostProcessors.process(null, null, null); + } + + @Test + void shouldProcessWithOneProcessor() { + var processor = mock(ElementTagPostProcessor.class); + var tag = mock(IProcessableElementTag.class); + doReturn(Mono.empty()).when(processor).process(null, tag, null); + when(extensionGetter.getExtensions(ElementTagPostProcessor.class)) + .thenReturn(Flux.just(processor)); + elementTagPostProcessors.process(null, tag, null); + } + + @Test + void shouldProcessWithTwoProcessors() { + var processor1 = mock(ElementTagPostProcessor.class); + var processor2 = mock(ElementTagPostProcessor.class); + var tag = mock(IProcessableElementTag.class); + doReturn(Mono.empty()).when(processor1).process(null, tag, null); + doReturn(Mono.empty()).when(processor2).process(null, tag, null); + when(extensionGetter.getExtensions(ElementTagPostProcessor.class)) + .thenReturn(Flux.just(processor1, processor2)); + elementTagPostProcessors.process(null, tag, null); + var inOrder = inOrder(processor1, processor2); + inOrder.verify(processor1).process(null, tag, null); + inOrder.verify(processor2).process(null, tag, null); + } + + @Test + void shouldProcessWithFailure() { + var tag = mock(IProcessableElementTag.class); + var processor1 = mock(ElementTagPostProcessor.class); + var processor2 = mock(ElementTagPostProcessor.class); + doReturn(Mono.error(new IllegalStateException("failed to process"))) + .when(processor1).process(null, tag, null); + when(extensionGetter.getExtensions(ElementTagPostProcessor.class)) + .thenReturn(Flux.just(processor1, processor2)); + + var e = assertThrows(TemplateProcessingException.class, + () -> elementTagPostProcessors.process(null, tag, null) + ); + assertInstanceOf(IllegalStateException.class, e.getCause()); + assertEquals("failed to process", e.getCause().getMessage()); + verify(processor2, never()).process(null, tag, null); + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/theme/dialect/HaloProcessorDialectTest.java b/application/src/test/java/run/halo/app/theme/dialect/HaloProcessorDialectTest.java index c4c18afe500..15418c51f3b 100644 --- a/application/src/test/java/run/halo/app/theme/dialect/HaloProcessorDialectTest.java +++ b/application/src/test/java/run/halo/app/theme/dialect/HaloProcessorDialectTest.java @@ -79,7 +79,7 @@ class HaloProcessorDialectTest { @BeforeEach void setUp() { - HaloProcessorDialect haloProcessorDialect = new HaloProcessorDialect(); + HaloProcessorDialect haloProcessorDialect = new HaloProcessorDialect(extensionGetter); templateEngine = new TemplateEngine(); templateEngine.setDialects(Set.of(haloProcessorDialect, new SpringStandardDialect())); templateEngine.addTemplateResolver(new TestTemplateResolver()); @@ -113,6 +113,7 @@ void setUp() { lenient().when(extensionGetter.getExtensions(TemplateHeadProcessor.class)).thenReturn( Flux.fromIterable(map.values()).sort(AnnotationAwareOrderComparator.INSTANCE) ); + when(extensionGetter.getExtensions(ElementTagPostProcessor.class)).thenReturn(Flux.empty()); lenient().when(fetcher.fetchComment()) .thenReturn(Mono.just(new SystemSetting.Comment())); diff --git a/application/src/test/java/run/halo/app/theme/dialect/TemplateFooterElementTagProcessorTest.java b/application/src/test/java/run/halo/app/theme/dialect/TemplateFooterElementTagProcessorTest.java index 9fec46b91ef..68a7257980d 100644 --- a/application/src/test/java/run/halo/app/theme/dialect/TemplateFooterElementTagProcessorTest.java +++ b/application/src/test/java/run/halo/app/theme/dialect/TemplateFooterElementTagProcessorTest.java @@ -56,7 +56,7 @@ class TemplateFooterElementTagProcessorTest { @BeforeEach void setUp() { - HaloProcessorDialect haloProcessorDialect = new MockHaloProcessorDialect(); + HaloProcessorDialect haloProcessorDialect = new MockHaloProcessorDialect(extensionGetter); templateEngine = new TemplateEngine(); templateEngine.setDialects(Set.of(haloProcessorDialect, new SpringStandardDialect())); templateEngine.addTemplateResolver(new MockTemplateResolver()); @@ -111,6 +111,10 @@ protected ITemplateResource computeTemplateResource(IEngineConfiguration configu } static class MockHaloProcessorDialect extends HaloProcessorDialect { + MockHaloProcessorDialect(ExtensionGetter extensionGetter) { + super(extensionGetter); + } + @Override public Set getProcessors(String dialectPrefix) { var processors = new HashSet();