Provide ElementTagProcessor to handle element tag in plugin (#6670)

#### 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<Void> 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
支持在插件中操作渲染结果
```
pull/6679/head
John Niang 2024-09-19 10:56:53 +08:00 committed by GitHub
parent ded5b4135f
commit 07077f7d0c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 339 additions and 3 deletions

View File

@ -1,5 +1,6 @@
package run.halo.app.plugin.extensionpoint; package run.halo.app.plugin.extensionpoint;
import java.util.List;
import org.pf4j.ExtensionPoint; import org.pf4j.ExtensionPoint;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
@ -34,4 +35,14 @@ public interface ExtensionGetter {
* @return a bunch of extension points. * @return a bunch of extension points.
*/ */
<T extends ExtensionPoint> Flux<T> getExtensions(Class<T> extensionPointClass); <T extends ExtensionPoint> Flux<T> getExtensions(Class<T> extensionPointClass);
/**
* Get all extensions according to extension point class.
*
* @param extensionPointClass extension point class
* @param <T> type of extension point
* @return a bunch of extension points.
*/
<T extends ExtensionPoint> List<T> getExtensionList(Class<T> extensionPointClass);
} }

View File

@ -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 {
/**
* <p>
* Execute the processor.
* </p>
* <p>
* 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.
* </p>
* <p>
* Don't forget to return the new tag after processing or
* {@link reactor.core.publisher.Mono#empty()} if not processable.
* </p>
*
* @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<IProcessableElementTag> process(
ITemplateContext context,
final IProcessableElementTag tag
);
}

View File

@ -2,7 +2,10 @@ package run.halo.app.plugin.extensionpoint;
import static run.halo.app.extension.index.query.QueryFactory.equal; 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.Objects;
import java.util.Optional;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.pf4j.ExtensionPoint; import org.pf4j.ExtensionPoint;
import org.pf4j.PluginManager; import org.pf4j.PluginManager;
@ -41,6 +44,16 @@ public class DefaultExtensionGetter implements ExtensionGetter {
.sort(new AnnotationAwareOrderComparator()); .sort(new AnnotationAwareOrderComparator());
} }
@Override
public <T extends ExtensionPoint> List<T> getExtensionList(Class<T> extensionPoint) {
var extensions = new LinkedList<T>();
Optional.ofNullable(pluginManager.getExtensions(extensionPoint))
.ifPresent(extensions::addAll);
extensions.addAll(beanFactory.getBeanProvider(extensionPoint).orderedStream().toList());
extensions.sort(new AnnotationAwareOrderComparator());
return extensions;
}
@Override @Override
public <T extends ExtensionPoint> Mono<T> getEnabledExtension(Class<T> extensionPoint) { public <T extends ExtensionPoint> Mono<T> getEnabledExtension(Class<T> extensionPoint) {
return getEnabledExtensions(extensionPoint).next(); return getEnabledExtensions(extensionPoint).next();

View File

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

View File

@ -1,10 +1,15 @@
package run.halo.app.theme.dialect; package run.halo.app.theme.dialect;
import static org.thymeleaf.templatemode.TemplateMode.HTML;
import java.util.HashSet; import java.util.HashSet;
import java.util.Set; import java.util.Set;
import org.thymeleaf.dialect.AbstractProcessorDialect; import org.thymeleaf.dialect.AbstractProcessorDialect;
import org.thymeleaf.dialect.IExpressionObjectDialect; import org.thymeleaf.dialect.IExpressionObjectDialect;
import org.thymeleaf.dialect.IPostProcessorDialect;
import org.thymeleaf.expression.IExpressionObjectFactory; import org.thymeleaf.expression.IExpressionObjectFactory;
import org.thymeleaf.postprocessor.IPostProcessor;
import org.thymeleaf.postprocessor.PostProcessor;
import org.thymeleaf.processor.IProcessor; import org.thymeleaf.processor.IProcessor;
import org.thymeleaf.standard.StandardDialect; import org.thymeleaf.standard.StandardDialect;
@ -14,8 +19,8 @@ import org.thymeleaf.standard.StandardDialect;
* @author guqing * @author guqing
* @since 2.0.0 * @since 2.0.0
*/ */
public class HaloProcessorDialect extends AbstractProcessorDialect implements public class HaloProcessorDialect extends AbstractProcessorDialect
IExpressionObjectDialect { implements IExpressionObjectDialect, IPostProcessorDialect {
private static final String DIALECT_NAME = "haloThemeProcessorDialect"; private static final String DIALECT_NAME = "haloThemeProcessorDialect";
private static final IExpressionObjectFactory HALO_EXPRESSION_OBJECTS_FACTORY = private static final IExpressionObjectFactory HALO_EXPRESSION_OBJECTS_FACTORY =
@ -43,4 +48,14 @@ public class HaloProcessorDialect extends AbstractProcessorDialect implements
public IExpressionObjectFactory getExpressionObjectFactory() { public IExpressionObjectFactory getExpressionObjectFactory() {
return HALO_EXPRESSION_OBJECTS_FACTORY; return HALO_EXPRESSION_OBJECTS_FACTORY;
} }
@Override
public int getDialectPostProcessorPrecedence() {
return Integer.MAX_VALUE;
}
@Override
public Set<IPostProcessor> getPostProcessors() {
return Set.of(new PostProcessor(HTML, HaloPostTemplateHandler.class, Integer.MAX_VALUE));
}
} }

View File

@ -1,5 +1,6 @@
package run.halo.app.plugin.extensionpoint; 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.any;
import static org.mockito.ArgumentMatchers.same; import static org.mockito.ArgumentMatchers.same;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
@ -44,6 +45,9 @@ class DefaultExtensionGetterTest {
@Mock @Mock
BeanFactory beanFactory; BeanFactory beanFactory;
@Mock
ObjectProvider<FakeExtensionPoint> extensionPointObjectProvider;
@InjectMocks @InjectMocks
DefaultExtensionGetter getter; DefaultExtensionGetter getter;
@ -209,6 +213,20 @@ class DefaultExtensionGetterTest {
.verifyComplete(); .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 { interface FakeExtensionPoint extends ExtensionPoint {
} }

View File

@ -3,6 +3,7 @@ package run.halo.app.theme;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.when;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ArrayNode; 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.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContext;
import org.thymeleaf.IEngineConfiguration; import org.thymeleaf.IEngineConfiguration;
import org.thymeleaf.TemplateEngine; import org.thymeleaf.TemplateEngine;
@ -29,6 +31,7 @@ import reactor.core.publisher.Mono;
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.infra.utils.JsonUtils; import run.halo.app.infra.utils.JsonUtils;
import run.halo.app.plugin.extensionpoint.ExtensionGetter;
import run.halo.app.theme.dialect.HaloProcessorDialect; import run.halo.app.theme.dialect.HaloProcessorDialect;
/** /**
@ -44,6 +47,9 @@ public class ReactiveFinderExpressionParserTests {
@Mock @Mock
private ApplicationContext applicationContext; private ApplicationContext applicationContext;
@Mock
private ObjectProvider<ExtensionGetter> extensionGetterProvider;
@Mock @Mock
private SystemConfigurableEnvironmentFetcher environmentFetcher; private SystemConfigurableEnvironmentFetcher environmentFetcher;
@ -62,6 +68,9 @@ public class ReactiveFinderExpressionParserTests {
templateEngine.addTemplateResolver(new TestTemplateResolver()); templateEngine.addTemplateResolver(new TestTemplateResolver());
lenient().when(applicationContext.getBean(eq(SystemConfigurableEnvironmentFetcher.class))) lenient().when(applicationContext.getBean(eq(SystemConfigurableEnvironmentFetcher.class)))
.thenReturn(environmentFetcher); .thenReturn(environmentFetcher);
when(applicationContext.getBeanProvider(ExtensionGetter.class))
.thenReturn(extensionGetterProvider);
when(extensionGetterProvider.getIfUnique()).thenReturn(null);
lenient().when(environmentFetcher.fetchComment()) lenient().when(environmentFetcher.fetchComment())
.thenReturn(Mono.just(new SystemSetting.Comment())); .thenReturn(Mono.just(new SystemSetting.Comment()));
} }

View File

@ -13,6 +13,7 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContext;
import org.thymeleaf.IEngineConfiguration; import org.thymeleaf.IEngineConfiguration;
import org.thymeleaf.TemplateEngine; import org.thymeleaf.TemplateEngine;
@ -48,6 +49,9 @@ class CommentElementTagProcessorTest {
@Mock @Mock
private ExtensionGetter extensionGetter; private ExtensionGetter extensionGetter;
@Mock
private ObjectProvider<ExtensionGetter> extensionGetterProvider;
@Mock @Mock
private SystemConfigurableEnvironmentFetcher environmentFetcher; private SystemConfigurableEnvironmentFetcher environmentFetcher;
@ -61,6 +65,9 @@ class CommentElementTagProcessorTest {
templateEngine.addTemplateResolver(new TestTemplateResolver()); templateEngine.addTemplateResolver(new TestTemplateResolver());
lenient().when(applicationContext.getBean(eq(ExtensionGetter.class))) lenient().when(applicationContext.getBean(eq(ExtensionGetter.class)))
.thenReturn(extensionGetter); .thenReturn(extensionGetter);
when(applicationContext.getBeanProvider(ExtensionGetter.class))
.thenReturn(extensionGetterProvider);
when(extensionGetterProvider.getIfUnique()).thenReturn(null);
} }
@Test @Test

View File

@ -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<ExtensionGetter> 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<ElementTagPostProcessor> 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<List<ElementTagPostProcessor>> provideEmptyElementTagProcessors() {
return Stream.of(
null,
List.of()
);
}
}