diff --git a/api/src/main/java/run/halo/app/core/extension/Plugin.java b/api/src/main/java/run/halo/app/core/extension/Plugin.java index e4cf014cc..168afd449 100644 --- a/api/src/main/java/run/halo/app/core/extension/Plugin.java +++ b/api/src/main/java/run/halo/app/core/extension/Plugin.java @@ -61,10 +61,11 @@ public class Plugin extends AbstractExtension { * * @see semantic version */ - @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; diff --git a/api/src/main/java/run/halo/app/theme/ReactivePostContentHandler.java b/api/src/main/java/run/halo/app/theme/ReactivePostContentHandler.java new file mode 100644 index 000000000..9dba0f272 --- /dev/null +++ b/api/src/main/java/run/halo/app/theme/ReactivePostContentHandler.java @@ -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; + +/** + *

{@link ReactivePostContentHandler} provides a way to extend the content to be displayed in + * the theme.

+ * 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 { + + /** + *

Methods for handling {@link run.halo.app.core.extension.content.Post} content.

+ *

For example, you can use this method to change the content for a better display in + * theme-side.

+ * + * @param postContent content to be handled + * @return handled content + */ + Mono handle(@NonNull PostContentContext postContent); + + @Data + @Builder + class PostContentContext { + private Post post; + private String content; + private String raw; + private String rawType; + } +} diff --git a/api/src/main/java/run/halo/app/theme/ReactiveSinglePageContentHandler.java b/api/src/main/java/run/halo/app/theme/ReactiveSinglePageContentHandler.java new file mode 100644 index 000000000..980ddce91 --- /dev/null +++ b/api/src/main/java/run/halo/app/theme/ReactiveSinglePageContentHandler.java @@ -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; + +/** + *

{@link ReactiveSinglePageContentHandler} provides a way to extend the content to be + * displayed in the theme.

+ * + * @author guqing + * @see ReactivePostContentHandler + * @since 2.7.0 + */ +public interface ReactiveSinglePageContentHandler extends ExtensionPoint { + + /** + *

Methods for handling {@link run.halo.app.core.extension.content.SinglePage} content.

+ *

For example, you can use this method to change the content for a better display in + * theme-side.

+ * + * @param singlePageContent content to be handled + * @return handled content + */ + Mono handle(@NonNull SinglePageContentContext singlePageContent); + + @Data + @Builder + class SinglePageContentContext { + private SinglePage singlePage; + private String content; + private String raw; + private String rawType; + } +} diff --git a/application/src/main/java/run/halo/app/content/AbstractContentService.java b/application/src/main/java/run/halo/app/content/AbstractContentService.java index 97b50866a..17873479b 100644 --- a/application/src/main/java/run/halo/app/content/AbstractContentService.java +++ b/application/src/main/java/run/halo/app/content/AbstractContentService.java @@ -26,14 +26,14 @@ public abstract class AbstractContentService { private final ReactiveExtensionClient client; public Mono 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())); diff --git a/application/src/main/java/run/halo/app/theme/finders/PostPublicQueryService.java b/application/src/main/java/run/halo/app/theme/finders/PostPublicQueryService.java index d20afb63e..b2417fa8e 100644 --- a/application/src/main/java/run/halo/app/theme/finders/PostPublicQueryService.java +++ b/application/src/main/java/run/halo/app/theme/finders/PostPublicQueryService.java @@ -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 FIXED_PREDICATE = post -> post.isPublished() @@ -34,5 +37,27 @@ public interface PostPublicQueryService { * @param post post must not be null * @return listed post vo */ - Mono convertToListedPostVo(@NonNull Post post); + Mono convertToListedVo(@NonNull Post post); + + /** + * Converts {@link Post} to post vo and populate post content by the given snapshot name. + *

This method will get post content by {@code snapshotName} and try to find + * {@link ReactivePostContentHandler}s to extend the content

+ * + * @param post post must not be null + * @param snapshotName snapshot name must not be blank + * @return converted post vo + */ + Mono convertToVo(Post post, String snapshotName); + + /** + * Gets post content by post name. + *

This method will get post released content by post name and try to find + * {@link ReactivePostContentHandler}s to extend the content

+ * + * @param postName post name must not be blank + * @return post content for theme-side + * @see ReactivePostContentHandler + */ + Mono getContent(String postName); } diff --git a/application/src/main/java/run/halo/app/theme/finders/SinglePageConversionService.java b/application/src/main/java/run/halo/app/theme/finders/SinglePageConversionService.java index 7eac0de5e..15f6a8541 100644 --- a/application/src/main/java/run/halo/app/theme/finders/SinglePageConversionService.java +++ b/application/src/main/java/run/halo/app/theme/finders/SinglePageConversionService.java @@ -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 convertToVo(SinglePage singlePage, String snapshotName); - Mono convertToVo(SinglePage singlePage); + /** + * Converts the given {@link SinglePage} to {@link SinglePageVo}. + *

This method will query the additional information of the {@link SinglePageVo} needed to + * populate.

+ *

This method will try to find {@link ReactiveSinglePageContentHandler}s to extend the + * content.

+ * + * @param singlePage the single page must not be null + * @return the converted single page vo + * @see #getContent(String) + */ + Mono convertToVo(@NonNull SinglePage singlePage); + + /** + * Gets content by given page name. + *

This method will get released content by page name and try to find + * {@link ReactiveSinglePageContentHandler}s to extend the content.

+ * + * @param pageName page name must not be blank + * @return content of the specified page + * @since 2.7.0 + */ + Mono getContent(String pageName); Mono convertToListedVo(SinglePage singlePage); } diff --git a/application/src/main/java/run/halo/app/theme/finders/impl/PostFinderImpl.java b/application/src/main/java/run/halo/app/theme/finders/impl/PostFinderImpl.java index c8684b28f..db3757b6b 100644 --- a/application/src/main/java/run/halo/app/theme/finders/impl/PostFinderImpl.java +++ b/application/src/main/java/run/halo/app/theme/finders/impl/PostFinderImpl.java @@ -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 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 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 listAll() { return client.list(Post.class, FIXED_PREDICATE, defaultComparator()) - .concatMap(postPublicQueryService::convertToListedPostVo); + .concatMap(postPublicQueryService::convertToListedVo); } static Pair postPreviousNextPair(List postNames, diff --git a/application/src/main/java/run/halo/app/theme/finders/impl/PostPublicQueryServiceImpl.java b/application/src/main/java/run/halo/app/theme/finders/impl/PostPublicQueryServiceImpl.java index faf2b8746..e3065dfd1 100644 --- a/application/src/main/java/run/halo/app/theme/finders/impl/PostPublicQueryServiceImpl.java +++ b/application/src/main/java/run/halo/app/theme/finders/impl/PostPublicQueryServiceImpl.java @@ -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> list(Integer page, Integer size, Predicate postPredicate, Comparator 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 convertToListedPostVo(@NonNull Post post) { + public Mono 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 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 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 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 Mono populateStats(T postVo) { return counterService.getByName(MeterUtils.nameOf(Post.class, postVo.getMetadata() .getName()) diff --git a/application/src/main/java/run/halo/app/theme/finders/impl/SinglePageConversionServiceImpl.java b/application/src/main/java/run/halo/app/theme/finders/impl/SinglePageConversionServiceImpl.java index 8013a688d..4ca729bf2 100644 --- a/application/src/main/java/run/halo/app/theme/finders/impl/SinglePageConversionServiceImpl.java +++ b/application/src/main/java/run/halo/app/theme/finders/impl/SinglePageConversionServiceImpl.java @@ -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 convertToVo(SinglePage singlePage, String snapshotName) { return convert(singlePage, snapshotName); } @Override - public Mono convertToVo(SinglePage singlePage) { + public Mono convertToVo(@NonNull SinglePage singlePage) { return convert(singlePage, singlePage.getSpec().getReleaseSnapshot()); } + protected Mono 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 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 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 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); - } - Mono populateStats(T pageVo) { String name = pageVo.getMetadata().getName(); return counterService.getByName(MeterUtils.nameOf(SinglePage.class, name)) diff --git a/application/src/main/java/run/halo/app/theme/finders/impl/SinglePageFinderImpl.java b/application/src/main/java/run/halo/app/theme/finders/impl/SinglePageFinderImpl.java index af2b2c45d..af97747d0 100644 --- a/application/src/main/java/run/halo/app/theme/finders/impl/SinglePageFinderImpl.java +++ b/application/src/main/java/run/halo/app/theme/finders/impl/SinglePageFinderImpl.java @@ -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 getByName(String pageName) { return client.get(SinglePage.class, pageName) @@ -53,9 +50,7 @@ public class SinglePageFinderImpl implements SinglePageFinder { @Override public Mono content(String pageName) { - return singlePageService.getReleaseContent(pageName) - .map(wrapper -> ContentVo.builder().content(wrapper.getContent()) - .raw(wrapper.getRaw()).build()); + return singlePagePublicQueryService.getContent(pageName); } @Override diff --git a/application/src/main/java/run/halo/app/theme/router/PreviewRouterFunction.java b/application/src/main/java/run/halo/app/theme/router/PreviewRouterFunction.java index 1657befe3..f60342cd5 100644 --- a/application/src/main/java/run/halo/app/theme/router/PreviewRouterFunction.java +++ b/application/src/main/java/run/halo/app/theme/router/PreviewRouterFunction.java @@ -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 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 previewSinglePage(ServerRequest request) { diff --git a/application/src/main/resources/extensions/extensionpoint-definitions.yaml b/application/src/main/resources/extensions/extensionpoint-definitions.yaml index 4a7b15c02..3072f6a2a 100644 --- a/application/src/main/resources/extensions/extensionpoint-definitions.yaml +++ b/application/src/main/resources/extensions/extensionpoint-definitions.yaml @@ -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." diff --git a/application/src/test/java/run/halo/app/theme/finders/impl/PostFinderImplTest.java b/application/src/test/java/run/halo/app/theme/finders/impl/PostFinderImplTest.java index 9120c7343..6c783cf0d 100644 --- a/application/src/test/java/run/halo/app/theme/finders/impl/PostFinderImplTest.java +++ b/application/src/test/java/run/halo/app/theme/finders/impl/PostFinderImplTest.java @@ -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 strings = posts().stream().sorted(PostFinderImpl.defaultComparator()) diff --git a/application/src/test/java/run/halo/app/theme/finders/impl/PostPublicQueryServiceImplTest.java b/application/src/test/java/run/halo/app/theme/finders/impl/PostPublicQueryServiceImplTest.java new file mode 100644 index 000000000..67a33536a --- /dev/null +++ b/application/src/test/java/run/halo/app/theme/finders/impl/PostPublicQueryServiceImplTest.java @@ -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 handle(PostContentContext postContent) { + postContent.setContent(postContent.getContent() + "-A"); + return Mono.just(postContent); + } + } + + static class PostContentHandlerB implements ReactivePostContentHandler { + + @Override + public Mono handle(PostContentContext postContent) { + postContent.setContent(postContent.getContent() + "-B"); + return Mono.just(postContent); + } + } + + static class PostContentHandlerC implements ReactivePostContentHandler { + + @Override + public Mono handle(PostContentContext postContent) { + postContent.setContent(postContent.getContent() + "-C"); + return Mono.just(postContent); + } + } +} diff --git a/application/src/test/java/run/halo/app/theme/finders/impl/SinglePageConversionServiceImplTest.java b/application/src/test/java/run/halo/app/theme/finders/impl/SinglePageConversionServiceImplTest.java new file mode 100644 index 000000000..a2e767ff5 --- /dev/null +++ b/application/src/test/java/run/halo/app/theme/finders/impl/SinglePageConversionServiceImplTest.java @@ -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 handle( + @NonNull SinglePageContentContext pageContent) { + pageContent.setContent(pageContent.getContent() + "-A"); + return Mono.just(pageContent); + } + } + + static class PageContentHandlerB implements ReactiveSinglePageContentHandler { + + @Override + public Mono handle( + @NonNull SinglePageContentContext pageContent) { + pageContent.setContent(pageContent.getContent() + "-B"); + return Mono.just(pageContent); + } + } + + static class PageContentHandlerC implements ReactiveSinglePageContentHandler { + + @Override + public Mono handle( + @NonNull SinglePageContentContext pageContent) { + pageContent.setContent(pageContent.getContent() + "-C"); + return Mono.just(pageContent); + } + } +} diff --git a/application/src/test/java/run/halo/app/theme/router/PreviewRouterFunctionTest.java b/application/src/test/java/run/halo/app/theme/router/PreviewRouterFunctionTest.java index 1e635ccf6..a039ce9f1 100644 --- a/application/src/test/java/run/halo/app/theme/router/PreviewRouterFunctionTest.java +++ b/application/src/test/java/run/halo/app/theme/router/PreviewRouterFunctionTest.java @@ -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")); } diff --git a/docs/extension-points/content.md b/docs/extension-points/content.md new file mode 100644 index 000000000..31b578783 --- /dev/null +++ b/docs/extension-points/content.md @@ -0,0 +1,107 @@ +# 内容扩展点 + +## 文章内容扩展点 + +文章内容扩展点用于在主题端文章内容渲染之前对文章内容进行修改,比如添加广告、添加版权声明、插入脚本等。 + +## 使用方式 + +在插件中通过实现 `run.halo.app.theme.ReactivePostContentHandler` 接口来实现文章内容扩展。 + +以下是一个扩展文章内容支持 Katex 的示例: + +```javascript +String katexScript=""" + + + + + """; +``` + +然后在 `handle` 方法中将 Katex 的脚本字符串插入到内容前面: + +```java + +@Component +public class KatexPostContentHandler implements ReactivePostContentHandler { + + @Override + public Mono 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 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." +```