mirror of https://github.com/halo-dev/halo
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
parent
ded5b4135f
commit
07077f7d0c
|
@ -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.
|
||||
*/
|
||||
<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);
|
||||
|
||||
}
|
||||
|
|
|
@ -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
|
||||
);
|
||||
|
||||
}
|
|
@ -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 <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
|
||||
public <T extends ExtensionPoint> Mono<T> getEnabledExtension(Class<T> extensionPoint) {
|
||||
return getEnabledExtensions(extensionPoint).next();
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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<IPostProcessor> getPostProcessors() {
|
||||
return Set.of(new PostProcessor(HTML, HaloPostTemplateHandler.class, Integer.MAX_VALUE));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<FakeExtensionPoint> 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 {
|
||||
|
||||
}
|
||||
|
|
|
@ -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<ExtensionGetter> 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}]]*/;
|
||||
</script>
|
||||
""");
|
||||
""");
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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<ExtensionGetter> 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
|
||||
|
|
|
@ -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()
|
||||
);
|
||||
}
|
||||
|
||||
}
|
Loading…
Reference in New Issue