feat: add content extension points for post and single page on theme-side (#4080)

#### What type of PR is this?
/kind feature
/milestone 2.7.x
/area core

#### What this PR does / why we need it:
为主题端的文章和自定义页面内容添加扩展点
插件可以通过实现扩展点来干预文章和自定义页面的内容显示,如修改内容的 html 结构,改变特定样式等

使用方式参考:[docs/extension-points/content.md](9b2b9f1837)

#### Which issue(s) this PR fixes:

Fixes #4003

#### Does this PR introduce a user-facing change?

```release-note
为主题端的文章和自定义页面内容添加扩展点
```
pull/4128/head^2
guqing 2023-06-28 23:30:11 +08:00 committed by GitHub
parent 972ebed03a
commit cabcd98ef4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 571 additions and 88 deletions

View File

@ -61,10 +61,11 @@ public class Plugin extends AbstractExtension {
*
* @see <a href="semver.org">semantic version</a>
*/
@Schema(requiredMode = REQUIRED, pattern = "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-("
+ "(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\."
+ "(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\\+([0-9a-zA-Z-]+(?:\\"
+ ".[0-9a-zA-Z-]+)*))?$")
@Schema(requiredMode = REQUIRED,
pattern = "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-("
+ "(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\."
+ "(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\\+([0-9a-zA-Z-]+(?:\\"
+ ".[0-9a-zA-Z-]+)*))?$")
private String version;
private PluginAuthor author;

View File

@ -0,0 +1,40 @@
package run.halo.app.theme;
import lombok.Builder;
import lombok.Data;
import org.pf4j.ExtensionPoint;
import org.springframework.lang.NonNull;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.content.Post;
/**
* <p>{@link ReactivePostContentHandler} provides a way to extend the content to be displayed in
* the theme.</p>
* Plugins can implement this interface to extend the content to be displayed in the theme,
* including but not limited to adding specific styles, JS libraries, inserting specific content,
* and intercepting content.
*
* @author guqing
* @since 2.7.0
*/
public interface ReactivePostContentHandler extends ExtensionPoint {
/**
* <p>Methods for handling {@link run.halo.app.core.extension.content.Post} content.</p>
* <p>For example, you can use this method to change the content for a better display in
* theme-side.</p>
*
* @param postContent content to be handled
* @return handled content
*/
Mono<PostContentContext> handle(@NonNull PostContentContext postContent);
@Data
@Builder
class PostContentContext {
private Post post;
private String content;
private String raw;
private String rawType;
}
}

View File

@ -0,0 +1,38 @@
package run.halo.app.theme;
import lombok.Builder;
import lombok.Data;
import org.pf4j.ExtensionPoint;
import org.springframework.lang.NonNull;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.content.SinglePage;
/**
* <p>{@link ReactiveSinglePageContentHandler} provides a way to extend the content to be
* displayed in the theme.</p>
*
* @author guqing
* @see ReactivePostContentHandler
* @since 2.7.0
*/
public interface ReactiveSinglePageContentHandler extends ExtensionPoint {
/**
* <p>Methods for handling {@link run.halo.app.core.extension.content.SinglePage} content.</p>
* <p>For example, you can use this method to change the content for a better display in
* theme-side.</p>
*
* @param singlePageContent content to be handled
* @return handled content
*/
Mono<SinglePageContentContext> handle(@NonNull SinglePageContentContext singlePageContent);
@Data
@Builder
class SinglePageContentContext {
private SinglePage singlePage;
private String content;
private String raw;
private String rawType;
}
}

View File

@ -26,14 +26,14 @@ public abstract class AbstractContentService {
private final ReactiveExtensionClient client;
public Mono<ContentWrapper> getContent(String snapshotName, String baseSnapshotName) {
return client.fetch(Snapshot.class, baseSnapshotName)
return client.get(Snapshot.class, baseSnapshotName)
.doOnNext(this::checkBaseSnapshot)
.flatMap(baseSnapshot -> {
if (StringUtils.equals(snapshotName, baseSnapshotName)) {
var contentWrapper = ContentWrapper.patchSnapshot(baseSnapshot, baseSnapshot);
return Mono.just(contentWrapper);
}
return client.fetch(Snapshot.class, snapshotName)
return client.get(Snapshot.class, snapshotName)
.map(snapshot -> ContentWrapper.patchSnapshot(snapshot, baseSnapshot));
});
}
@ -42,7 +42,7 @@ public abstract class AbstractContentService {
Assert.notNull(snapshot, "The snapshot must not be null.");
String keepRawAnno =
MetadataUtil.nullSafeAnnotations(snapshot).get(Snapshot.KEEP_RAW_ANNO);
if (!org.thymeleaf.util.StringUtils.equals(Boolean.TRUE.toString(), keepRawAnno)) {
if (!StringUtils.equals(Boolean.TRUE.toString(), keepRawAnno)) {
throw new IllegalArgumentException(
String.format("The snapshot [%s] is not a base snapshot.",
snapshot.getMetadata().getName()));

View File

@ -7,7 +7,10 @@ import org.springframework.lang.NonNull;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.content.Post;
import run.halo.app.extension.ListResult;
import run.halo.app.theme.ReactivePostContentHandler;
import run.halo.app.theme.finders.vo.ContentVo;
import run.halo.app.theme.finders.vo.ListedPostVo;
import run.halo.app.theme.finders.vo.PostVo;
public interface PostPublicQueryService {
Predicate<Post> FIXED_PREDICATE = post -> post.isPublished()
@ -34,5 +37,27 @@ public interface PostPublicQueryService {
* @param post post must not be null
* @return listed post vo
*/
Mono<ListedPostVo> convertToListedPostVo(@NonNull Post post);
Mono<ListedPostVo> convertToListedVo(@NonNull Post post);
/**
* Converts {@link Post} to post vo and populate post content by the given snapshot name.
* <p> This method will get post content by {@code snapshotName} and try to find
* {@link ReactivePostContentHandler}s to extend the content</p>
*
* @param post post must not be null
* @param snapshotName snapshot name must not be blank
* @return converted post vo
*/
Mono<PostVo> convertToVo(Post post, String snapshotName);
/**
* Gets post content by post name.
* <p> This method will get post released content by post name and try to find
* {@link ReactivePostContentHandler}s to extend the content</p>
*
* @param postName post name must not be blank
* @return post content for theme-side
* @see ReactivePostContentHandler
*/
Mono<ContentVo> getContent(String postName);
}

View File

@ -1,7 +1,10 @@
package run.halo.app.theme.finders;
import org.springframework.lang.NonNull;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.content.SinglePage;
import run.halo.app.theme.ReactiveSinglePageContentHandler;
import run.halo.app.theme.finders.vo.ContentVo;
import run.halo.app.theme.finders.vo.ListedSinglePageVo;
import run.halo.app.theme.finders.vo.SinglePageVo;
@ -13,9 +16,40 @@ import run.halo.app.theme.finders.vo.SinglePageVo;
*/
public interface SinglePageConversionService {
/**
* Converts the given {@link SinglePage} to {@link SinglePageVo} and populate content by
* given snapshot name.
*
* @param singlePage the single page must not be null
* @param snapshotName the snapshot name to get content must not be blank
* @return the converted single page vo
* @see #convertToVo(SinglePage)
*/
Mono<SinglePageVo> convertToVo(SinglePage singlePage, String snapshotName);
Mono<SinglePageVo> convertToVo(SinglePage singlePage);
/**
* Converts the given {@link SinglePage} to {@link SinglePageVo}.
* <p>This method will query the additional information of the {@link SinglePageVo} needed to
* populate.</p>
* <p>This method will try to find {@link ReactiveSinglePageContentHandler}s to extend the
* content.</p>
*
* @param singlePage the single page must not be null
* @return the converted single page vo
* @see #getContent(String)
*/
Mono<SinglePageVo> convertToVo(@NonNull SinglePage singlePage);
/**
* Gets content by given page name.
* <p>This method will get released content by page name and try to find
* {@link ReactiveSinglePageContentHandler}s to extend the content.</p>
*
* @param pageName page name must not be blank
* @return content of the specified page
* @since 2.7.0
*/
Mono<ContentVo> getContent(String pageName);
Mono<ListedSinglePageVo> convertToListedVo(SinglePage singlePage);
}

View File

@ -18,7 +18,6 @@ import org.apache.commons.lang3.tuple.Pair;
import org.springframework.util.comparator.Comparators;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import run.halo.app.content.PostService;
import run.halo.app.core.extension.content.Post;
import run.halo.app.extension.ListResult;
import run.halo.app.extension.ReactiveExtensionClient;
@ -47,27 +46,20 @@ public class PostFinderImpl implements PostFinder {
private final ReactiveExtensionClient client;
private final PostService postService;
private final PostPublicQueryService postPublicQueryService;
@Override
public Mono<PostVo> getByName(String postName) {
return client.get(Post.class, postName)
.filter(FIXED_PREDICATE)
.flatMap(postPublicQueryService::convertToListedPostVo)
.map(PostVo::from)
.flatMap(postVo -> content(postName)
.doOnNext(postVo::setContent)
.thenReturn(postVo)
.flatMap(post -> postPublicQueryService.convertToVo(post,
post.getSpec().getReleaseSnapshot())
);
}
@Override
public Mono<ContentVo> content(String postName) {
return postService.getReleaseContent(postName)
.map(wrapper -> ContentVo.builder().content(wrapper.getContent())
.raw(wrapper.getRaw()).build());
return postPublicQueryService.getContent(postName);
}
@Override
@ -107,7 +99,7 @@ public class PostFinderImpl implements PostFinder {
@Override
public Flux<ListedPostVo> listAll() {
return client.list(Post.class, FIXED_PREDICATE, defaultComparator())
.concatMap(postPublicQueryService::convertToListedPostVo);
.concatMap(postPublicQueryService::convertToListedVo);
}
static Pair<String, String> postPreviousNextPair(List<String> postNames,

View File

@ -2,6 +2,7 @@ package run.halo.app.theme.finders.impl;
import java.util.Comparator;
import java.util.List;
import java.util.function.Function;
import java.util.function.Predicate;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.ObjectUtils;
@ -11,16 +12,22 @@ import org.springframework.util.Assert;
import org.springframework.util.CollectionUtils;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import run.halo.app.content.ContentWrapper;
import run.halo.app.content.PostService;
import run.halo.app.core.extension.content.Post;
import run.halo.app.extension.ListResult;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.metrics.CounterService;
import run.halo.app.metrics.MeterUtils;
import run.halo.app.plugin.extensionpoint.ExtensionGetter;
import run.halo.app.theme.ReactivePostContentHandler;
import run.halo.app.theme.finders.CategoryFinder;
import run.halo.app.theme.finders.ContributorFinder;
import run.halo.app.theme.finders.PostPublicQueryService;
import run.halo.app.theme.finders.TagFinder;
import run.halo.app.theme.finders.vo.ContentVo;
import run.halo.app.theme.finders.vo.ListedPostVo;
import run.halo.app.theme.finders.vo.PostVo;
import run.halo.app.theme.finders.vo.StatsVo;
@Component
@ -37,6 +44,10 @@ public class PostPublicQueryServiceImpl implements PostPublicQueryService {
private final CounterService counterService;
private final PostService postService;
private final ExtensionGetter extensionGetter;
@Override
public Mono<ListResult<ListedPostVo>> list(Integer page, Integer size,
Predicate<Post> postPredicate, Comparator<Post> comparator) {
@ -45,7 +56,7 @@ public class PostPublicQueryServiceImpl implements PostPublicQueryService {
return client.list(Post.class, predicate,
comparator, pageNullSafe(page), sizeNullSafe(size))
.flatMap(list -> Flux.fromStream(list.get())
.concatMap(post -> convertToListedPostVo(post)
.concatMap(post -> convertToListedVo(post)
.flatMap(postVo -> populateStats(postVo)
.doOnNext(postVo::setStats).thenReturn(postVo)
)
@ -59,7 +70,7 @@ public class PostPublicQueryServiceImpl implements PostPublicQueryService {
}
@Override
public Mono<ListedPostVo> convertToListedPostVo(@NonNull Post post) {
public Mono<ListedPostVo> convertToListedVo(@NonNull Post post) {
Assert.notNull(post, "Post must not be null");
ListedPostVo postVo = ListedPostVo.from(post);
postVo.setCategories(List.of());
@ -105,6 +116,53 @@ public class PostPublicQueryServiceImpl implements PostPublicQueryService {
.defaultIfEmpty(postVo);
}
@Override
public Mono<PostVo> convertToVo(Post post, String snapshotName) {
final String postName = post.getMetadata().getName();
final String baseSnapshotName = post.getSpec().getBaseSnapshot();
return convertToListedVo(post)
.map(PostVo::from)
.flatMap(postVo -> postService.getContent(snapshotName, baseSnapshotName)
.flatMap(wrapper -> extendPostContent(post, wrapper))
.doOnNext(postVo::setContent)
.thenReturn(postVo)
);
}
@Override
public Mono<ContentVo> getContent(String postName) {
return client.get(Post.class, postName)
.filter(FIXED_PREDICATE)
.flatMap(post -> {
String releaseSnapshot = post.getSpec().getReleaseSnapshot();
return postService.getContent(releaseSnapshot, post.getSpec().getBaseSnapshot())
.flatMap(wrapper -> extendPostContent(post, wrapper));
});
}
@NonNull
protected Mono<ContentVo> extendPostContent(Post post,
ContentWrapper wrapper) {
Assert.notNull(post, "Post name must not be null");
Assert.notNull(wrapper, "Post content must not be null");
return extensionGetter.getEnabledExtensionByDefinition(ReactivePostContentHandler.class)
.reduce(Mono.fromSupplier(() -> ReactivePostContentHandler.PostContentContext.builder()
.post(post)
.content(wrapper.getContent())
.raw(wrapper.getRaw())
.rawType(wrapper.getRawType())
.build()
),
(contentMono, handler) -> contentMono.flatMap(handler::handle)
)
.flatMap(Function.identity())
.map(postContent -> ContentVo.builder()
.content(postContent.getContent())
.raw(postContent.getRaw())
.build()
);
}
private <T extends ListedPostVo> Mono<StatsVo> populateStats(T postVo) {
return counterService.getByName(MeterUtils.nameOf(Post.class, postVo.getMetadata()
.getName())

View File

@ -1,15 +1,22 @@
package run.halo.app.theme.finders.impl;
import java.util.List;
import java.util.function.Function;
import lombok.RequiredArgsConstructor;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
import org.springframework.util.CollectionUtils;
import reactor.core.publisher.Mono;
import run.halo.app.content.ContentWrapper;
import run.halo.app.content.SinglePageService;
import run.halo.app.core.extension.content.SinglePage;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.metrics.CounterService;
import run.halo.app.metrics.MeterUtils;
import run.halo.app.plugin.extensionpoint.ExtensionGetter;
import run.halo.app.theme.ReactiveSinglePageContentHandler;
import run.halo.app.theme.ReactiveSinglePageContentHandler.SinglePageContentContext;
import run.halo.app.theme.finders.ContributorFinder;
import run.halo.app.theme.finders.SinglePageConversionService;
import run.halo.app.theme.finders.vo.ContentVo;
@ -27,22 +34,62 @@ import run.halo.app.theme.finders.vo.StatsVo;
@RequiredArgsConstructor
public class SinglePageConversionServiceImpl implements SinglePageConversionService {
private final ReactiveExtensionClient client;
private final SinglePageService singlePageService;
private final ContributorFinder contributorFinder;
private final CounterService counterService;
private final ExtensionGetter extensionGetter;
@Override
public Mono<SinglePageVo> convertToVo(SinglePage singlePage, String snapshotName) {
return convert(singlePage, snapshotName);
}
@Override
public Mono<SinglePageVo> convertToVo(SinglePage singlePage) {
public Mono<SinglePageVo> convertToVo(@NonNull SinglePage singlePage) {
return convert(singlePage, singlePage.getSpec().getReleaseSnapshot());
}
protected Mono<ContentVo> extendPageContent(SinglePage singlePage,
ContentWrapper wrapper) {
Assert.notNull(singlePage, "SinglePage must not be null");
Assert.notNull(wrapper, "SinglePage content must not be null");
return extensionGetter.getEnabledExtensionByDefinition(
ReactiveSinglePageContentHandler.class)
.reduce(Mono.fromSupplier(() -> SinglePageContentContext.builder()
.singlePage(singlePage)
.content(wrapper.getContent())
.raw(wrapper.getRaw())
.rawType(wrapper.getRawType())
.build()
),
(contentMono, handler) -> contentMono.flatMap(handler::handle)
)
.flatMap(Function.identity())
.map(pageContent -> ContentVo.builder()
.content(pageContent.getContent())
.raw(pageContent.getRaw())
.build()
);
}
@Override
public Mono<ContentVo> getContent(String pageName) {
return client.get(SinglePage.class, pageName)
.flatMap(singlePage -> {
String releaseSnapshot = singlePage.getSpec().getReleaseSnapshot();
String baseSnapshot = singlePage.getSpec().getBaseSnapshot();
return singlePageService.getContent(releaseSnapshot, baseSnapshot)
.flatMap(wrapper -> extendPageContent(singlePage, wrapper));
})
.map(wrapper -> ContentVo.builder().content(wrapper.getContent())
.raw(wrapper.getRaw()).build());
}
@Override
public Mono<ListedSinglePageVo> convertToListedVo(SinglePage singlePage) {
return Mono.fromSupplier(
@ -67,26 +114,19 @@ public class SinglePageConversionServiceImpl implements SinglePageConversionServ
})
.flatMap(this::populateStats)
.flatMap(this::populateContributors)
.flatMap(page -> populateContent(page, snapshotName))
.flatMap(page -> {
String baseSnapshot = page.getSpec().getBaseSnapshot();
return singlePageService.getContent(snapshotName, baseSnapshot)
.flatMap(wrapper -> extendPageContent(singlePage, wrapper))
.doOnNext(page::setContent)
.thenReturn(page);
})
.flatMap(page -> contributorFinder.getContributor(page.getSpec().getOwner())
.doOnNext(page::setOwner)
.thenReturn(page)
);
}
Mono<SinglePageVo> populateContent(SinglePageVo singlePageVo, String snapshotName) {
Assert.notNull(singlePageVo, "Single page vo must not be null");
Assert.hasText(snapshotName, "Snapshot name must not be empty");
return singlePageService.getContent(snapshotName, singlePageVo.getSpec().getBaseSnapshot())
.map(contentWrapper -> ContentVo.builder()
.content(contentWrapper.getContent())
.raw(contentWrapper.getRaw())
.build()
)
.doOnNext(singlePageVo::setContent)
.thenReturn(singlePageVo);
}
<T extends ListedSinglePageVo> Mono<T> populateStats(T pageVo) {
String name = pageVo.getMetadata().getName();
return counterService.getByName(MeterUtils.nameOf(SinglePage.class, name))

View File

@ -12,7 +12,6 @@ import org.apache.commons.lang3.ObjectUtils;
import org.springframework.lang.Nullable;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import run.halo.app.content.SinglePageService;
import run.halo.app.core.extension.content.Post;
import run.halo.app.core.extension.content.SinglePage;
import run.halo.app.extension.ListResult;
@ -42,8 +41,6 @@ public class SinglePageFinderImpl implements SinglePageFinder {
private final SinglePageConversionService singlePagePublicQueryService;
private final SinglePageService singlePageService;
@Override
public Mono<SinglePageVo> getByName(String pageName) {
return client.get(SinglePage.class, pageName)
@ -53,9 +50,7 @@ public class SinglePageFinderImpl implements SinglePageFinder {
@Override
public Mono<ContentVo> content(String pageName) {
return singlePageService.getReleaseContent(pageName)
.map(wrapper -> ContentVo.builder().content(wrapper.getContent())
.raw(wrapper.getRaw()).build());
return singlePagePublicQueryService.getContent(pageName);
}
@Override

View File

@ -26,7 +26,6 @@ import run.halo.app.infra.exception.NotFoundException;
import run.halo.app.theme.DefaultTemplateEnum;
import run.halo.app.theme.finders.PostPublicQueryService;
import run.halo.app.theme.finders.SinglePageConversionService;
import run.halo.app.theme.finders.vo.ContentVo;
import run.halo.app.theme.finders.vo.ContributorVo;
import run.halo.app.theme.finders.vo.PostVo;
@ -88,8 +87,7 @@ public class PreviewRouterFunction {
}
private Mono<PostVo> convertToPostVo(Post post, String snapshotName) {
return postPublicQueryService.convertToListedPostVo(post)
.map(PostVo::from)
return postPublicQueryService.convertToVo(post, snapshotName)
.doOnNext(postVo -> {
// fake some attributes only for preview when they are not published
Post.PostSpec spec = postVo.getSpec();
@ -107,17 +105,7 @@ public class PreviewRouterFunction {
if (status.getLastModifyTime() == null) {
status.setLastModifyTime(Instant.now());
}
})
.flatMap(postVo ->
postService.getContent(snapshotName, postVo.getSpec().getBaseSnapshot())
.map(contentWrapper -> ContentVo.builder()
.raw(contentWrapper.getRaw())
.content(contentWrapper.getContent())
.build()
)
.doOnNext(postVo::setContent)
.thenReturn(postVo)
);
});
}
private Mono<ServerResponse> previewSinglePage(ServerRequest request) {

View File

@ -8,3 +8,25 @@ spec:
type: MULTI_INSTANCE
description: "Contract for interception-style, chained processing of Web requests that may be used to
implement cross-cutting, application-agnostic requirements such as security, timeouts, and others."
---
apiVersion: plugin.halo.run/v1alpha1
kind: ExtensionPointDefinition
metadata:
name: reactive-post-content-handler
spec:
className: run.halo.app.theme.ReactivePostContentHandler
displayName: ReactivePostContentHandler
type: MULTI_INSTANCE
description: "Provides a way to extend the post content to be displayed in the theme-side."
---
apiVersion: plugin.halo.run/v1alpha1
kind: ExtensionPointDefinition
metadata:
name: reactive-singlepage-content-handler
spec:
className: run.halo.app.theme.ReactiveSinglePageContentHandler
displayName: ReactiveSinglePageContentHandler
type: MULTI_INSTANCE
description: "Provides a way to extend the single page content to be displayed in the theme-side."

View File

@ -3,7 +3,6 @@ package run.halo.app.theme.finders.impl;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
import static run.halo.app.theme.finders.PostPublicQueryService.FIXED_PREDICATE;
@ -20,7 +19,6 @@ import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import reactor.core.publisher.Mono;
import run.halo.app.content.ContentWrapper;
import run.halo.app.content.PostService;
import run.halo.app.core.extension.content.Post;
import run.halo.app.extension.ListResult;
@ -31,7 +29,6 @@ import run.halo.app.theme.finders.CategoryFinder;
import run.halo.app.theme.finders.ContributorFinder;
import run.halo.app.theme.finders.PostPublicQueryService;
import run.halo.app.theme.finders.TagFinder;
import run.halo.app.theme.finders.vo.ContentVo;
import run.halo.app.theme.finders.vo.ListedPostVo;
import run.halo.app.theme.finders.vo.PostArchiveVo;
import run.halo.app.theme.finders.vo.PostArchiveYearMonthVo;
@ -69,23 +66,6 @@ class PostFinderImplTest {
@InjectMocks
private PostFinderImpl postFinder;
@Test
void content() {
Post post = post(1);
post.getSpec().setReleaseSnapshot("release-snapshot");
ContentWrapper contentWrapper = ContentWrapper.builder()
.snapshotName("snapshot")
.raw("raw")
.content("content")
.rawType("rawType")
.build();
when(postService.getReleaseContent(eq(post.getMetadata().getName())))
.thenReturn(Mono.just(contentWrapper));
ContentVo content = postFinder.content("post-1").block();
assertThat(content.getContent()).isEqualTo(contentWrapper.getContent());
assertThat(content.getRaw()).isEqualTo(contentWrapper.getRaw());
}
@Test
void compare() {
List<String> strings = posts().stream().sorted(PostFinderImpl.defaultComparator())

View File

@ -0,0 +1,79 @@
package run.halo.app.theme.finders.impl;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.eq;
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 reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import run.halo.app.content.ContentWrapper;
import run.halo.app.content.TestPost;
import run.halo.app.core.extension.content.Post;
import run.halo.app.plugin.extensionpoint.ExtensionGetter;
import run.halo.app.theme.ReactivePostContentHandler;
/**
* Tests for {@link PostPublicQueryServiceImpl}.
*
* @author guqing
* @since 2.7.0
*/
@ExtendWith(MockitoExtension.class)
class PostPublicQueryServiceImplTest {
@Mock
private ExtensionGetter extensionGetter;
@InjectMocks
private PostPublicQueryServiceImpl postPublicQueryService;
@Test
void extendPostContent() {
when(extensionGetter.getEnabledExtensionByDefinition(
eq(ReactivePostContentHandler.class))).thenReturn(
Flux.just(new PostContentHandlerB(), new PostContentHandlerA(),
new PostContentHandlerC()));
Post post = TestPost.postV1();
post.getMetadata().setName("fake-post");
ContentWrapper contentWrapper =
ContentWrapper.builder().content("fake-content").raw("fake-raw").rawType("markdown")
.build();
postPublicQueryService.extendPostContent(post, contentWrapper)
.as(StepVerifier::create).consumeNextWith(contentVo -> {
assertThat(contentVo.getContent()).isEqualTo("fake-content-B-A-C");
}).verifyComplete();
}
static class PostContentHandlerA implements ReactivePostContentHandler {
@Override
public Mono<PostContentContext> handle(PostContentContext postContent) {
postContent.setContent(postContent.getContent() + "-A");
return Mono.just(postContent);
}
}
static class PostContentHandlerB implements ReactivePostContentHandler {
@Override
public Mono<PostContentContext> handle(PostContentContext postContent) {
postContent.setContent(postContent.getContent() + "-B");
return Mono.just(postContent);
}
}
static class PostContentHandlerC implements ReactivePostContentHandler {
@Override
public Mono<PostContentContext> handle(PostContentContext postContent) {
postContent.setContent(postContent.getContent() + "-C");
return Mono.just(postContent);
}
}
}

View File

@ -0,0 +1,91 @@
package run.halo.app.theme.finders.impl;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.eq;
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.springframework.lang.NonNull;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import run.halo.app.content.ContentWrapper;
import run.halo.app.core.extension.content.SinglePage;
import run.halo.app.extension.Metadata;
import run.halo.app.plugin.extensionpoint.ExtensionGetter;
import run.halo.app.theme.ReactiveSinglePageContentHandler;
/**
* Tests for {@link SinglePageConversionServiceImpl}.
*
* @author guqing
* @since 2.7.0
*/
@ExtendWith(MockitoExtension.class)
class SinglePageConversionServiceImplTest {
@Mock
private ExtensionGetter extensionGetter;
@InjectMocks
private SinglePageConversionServiceImpl pageConversionService;
@Test
void extendPageContent() {
when(extensionGetter.getEnabledExtensionByDefinition(
eq(ReactiveSinglePageContentHandler.class)))
.thenReturn(
Flux.just(new PageContentHandlerB(),
new PageContentHandlerA(),
new PageContentHandlerC())
);
ContentWrapper contentWrapper = ContentWrapper.builder()
.content("fake-content")
.raw("fake-raw")
.rawType("markdown")
.build();
SinglePage singlePage = new SinglePage();
singlePage.setMetadata(new Metadata());
singlePage.getMetadata().setName("fake-page");
pageConversionService.extendPageContent(singlePage, contentWrapper)
.as(StepVerifier::create)
.consumeNextWith(contentVo -> {
assertThat(contentVo.getContent()).isEqualTo("fake-content-B-A-C");
})
.verifyComplete();
}
static class PageContentHandlerA implements ReactiveSinglePageContentHandler {
@Override
public Mono<SinglePageContentContext> handle(
@NonNull SinglePageContentContext pageContent) {
pageContent.setContent(pageContent.getContent() + "-A");
return Mono.just(pageContent);
}
}
static class PageContentHandlerB implements ReactiveSinglePageContentHandler {
@Override
public Mono<SinglePageContentContext> handle(
@NonNull SinglePageContentContext pageContent) {
pageContent.setContent(pageContent.getContent() + "-B");
return Mono.just(pageContent);
}
}
static class PageContentHandlerC implements ReactiveSinglePageContentHandler {
@Override
public Mono<SinglePageContentContext> handle(
@NonNull SinglePageContentContext pageContent) {
pageContent.setContent(pageContent.getContent() + "-C");
return Mono.just(pageContent);
}
}
}

View File

@ -20,7 +20,6 @@ import org.springframework.web.reactive.function.server.HandlerStrategies;
import org.springframework.web.reactive.result.view.ViewResolver;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import run.halo.app.content.ContentWrapper;
import run.halo.app.content.PostService;
import run.halo.app.core.extension.content.Post;
import run.halo.app.core.extension.content.SinglePage;
@ -97,14 +96,8 @@ class PreviewRouterFunctionTest {
PostVo postVo = PostVo.from(post);
postVo.setContributors(contributorVos());
when(postPublicQueryService.convertToListedPostVo(post)).thenReturn(Mono.just(postVo));
ContentWrapper contentWrapper = ContentWrapper.builder()
.raw("raw content")
.content("formatted content")
.build();
when(postService.getContent(eq("snapshot1"), eq("snapshot2")))
.thenReturn(Mono.just(contentWrapper));
when(postPublicQueryService.convertToVo(eq(post), eq(post.getSpec().getHeadSnapshot())))
.thenReturn(Mono.just(postVo));
when(viewNameResolver.resolveViewNameOrDefault(any(), eq("postTemplate"),
eq("post"))).thenReturn(Mono.just("postView"));
@ -114,7 +107,7 @@ class PreviewRouterFunctionTest {
.expectStatus().isOk();
verify(viewResolver).resolveViewName(any(), any());
verify(postService).getContent(eq("snapshot1"), eq("snapshot2"));
verify(postPublicQueryService).convertToVo(eq(post), eq(post.getSpec().getHeadSnapshot()));
verify(client).fetch(eq(Post.class), eq("post1"));
}

View File

@ -0,0 +1,107 @@
# 内容扩展点
## 文章内容扩展点
文章内容扩展点用于在主题端文章内容渲染之前对文章内容进行修改,比如添加广告、添加版权声明、插入脚本等。
## 使用方式
在插件中通过实现 `run.halo.app.theme.ReactivePostContentHandler` 接口来实现文章内容扩展。
以下是一个扩展文章内容支持 Katex 的示例:
```javascript
String katexScript="""
<link rel="stylesheet" href="/plugins/plugin-katex/assets/static/katex.min.css">
<script defer src="/plugins/plugin-katex/assets/static/katex.min.js"></script>
<script defer src="/plugins/plugin-katex/assets/static/contrib/auto-render.min.js"></script>
<script>
document.addEventListener("DOMContentLoaded", function() {
renderMathInElement(document.body, {
// customised options
// • auto-render specific keys, e.g.:
delimiters: [
{left: '$$', right: '$$', display: true},
{left: '$', right: '$', display: false},
{left: '\\(', right: '\\)', display: false},
{left: '\\[', right: '\\]', display: true}
],
// • rendering keys, e.g.:
throwOnError : false
});
});
</script>
""";
```
然后在 `handle` 方法中将 Katex 的脚本字符串插入到内容前面:
```java
@Component
public class KatexPostContentHandler implements ReactivePostContentHandler {
@Override
public Mono<PostContentContext> handle(PostContentContext postContent) {
postContent.setContent(katexScript + "\n" + postContent.getContent());
return Mono.just(postContent);
}
}
```
定义了扩展点实现(扩展),还需要在插件的 `resources/extensions` 目录下添加对扩展的声明:
```yaml
# resources/extensions/extension-definitions.yml
apiVersion: plugin.halo.run/v1alpha1
kind: ExtensionDefinition
metadata:
name: ext-def-katex-post-content
spec:
className: run.halo.katex.KatexPostContentHandler
# 文章内容扩展点的名称,固定值
extensionPointName: reactive-post-content-handler
displayName: "KatexPostContentHandler"
description: "Katex support for post content."
```
## 自定义页面内容扩展点
自定义页面SinglePage内容扩展点用于在主题端自定义页面内容渲染之前对内容进行修改比如添加广告、添加版权声明、插入脚本等。
## 使用方式
在插件中通过实现 `run.halo.app.theme.ReactiveSinglePageContentHandler` 接口来实现内容扩展。
以下是一个扩展内容支持 Katex 的示例:
```java
@Component
public class KatexSinglePageContentHandler implements ReactiveSinglePageContentHandler {
@Override
public Mono<SinglePageContentContext> handle(SinglePageContentContext pageContent) {
String katexScript = ""; // 参考文章内容扩展点的示例脚本块
pageContent.setContent(katexScript + "\n" + pageContent.getContent());
return Mono.just(pageContent);
}
}
```
在插件的 `resources/extensions` 目录下添加对自定义页面内容扩展的声明:
```yaml
# resources/extensions/extension-definitions.yml
apiVersion: plugin.halo.run/v1alpha1
kind: ExtensionDefinition
metadata:
name: ext-def-katex-singlepage-content
spec:
className: run.halo.katex.KatexSinglePageContentHandler
# 自定义页面内容扩展点的名称,固定值
extensionPointName: reactive-post-content-handler
displayName: "KatexSinglePageContentHandler"
description: "Katex support for single page content."
```