feat: theme side provides variables for theme and some system settings (#2406)

#### What type of PR is this?
/kind feature
/milestone 2.0
/area core

#### What this PR does / why we need it:
提供当前使用主题(预览或激活)的 configMap 变量和部分系统设置等变量。

提供了以下变量:
- `${theme}` 当前主题的信息,theme.yaml 
- `${theme.config}` 获取当前主题的设置项
- ~`${siteSetting}`~ `${site}` 提供必要系统变量

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

#### Special notes for your reviewer:
how to test it?
再任意主题模板上使用表达式获取例如:`${theme}`,`${theme.config.sns?.email}`

/cc @halo-dev/sig-halo 
#### Does this PR introduce a user-facing change?

```release-note
None
```
pull/2415/head
guqing 2022-09-15 14:52:13 +08:00 committed by GitHub
parent 069ff04c84
commit a0d55c58f6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 796 additions and 101 deletions

View File

@ -74,6 +74,7 @@ dependencies {
implementation "com.google.guava:guava:$guava"
implementation "org.jsoup:jsoup:$jsoup"
implementation "io.github.java-diff-utils:java-diff-utils:$javaDiffUtils"
implementation "org.springframework.integration:spring-integration-core"
compileOnly 'org.projectlombok:lombok'
testCompileOnly 'org.projectlombok:lombok'

View File

@ -2,6 +2,7 @@ package run.halo.app;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.integration.IntegrationAutoConfiguration;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import run.halo.app.infra.properties.HaloProperties;
import run.halo.app.infra.properties.JwtProperties;
@ -14,7 +15,8 @@ import run.halo.app.infra.properties.JwtProperties;
* @author guqing
* @date 2017-11-14
*/
@SpringBootApplication
@SpringBootApplication(scanBasePackages = "run.halo.app", exclude =
IntegrationAutoConfiguration.class)
@EnableConfigurationProperties({HaloProperties.class, JwtProperties.class})
public class Application {

View File

@ -1,7 +1,10 @@
package run.halo.app.core.extension.reconciler;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.context.ApplicationContext;
import run.halo.app.extension.ConfigMap;
@ -19,13 +22,16 @@ import run.halo.app.theme.router.PermalinkRuleChangedEvent;
* @author guqing
* @since 2.0.0
*/
@Slf4j
public class SystemSettingReconciler implements Reconciler<Reconciler.Request> {
private static final String OLD_THEME_ROUTE_RULES = "halo.run/old-theme-route-rules";
public static final String OLD_THEME_ROUTE_RULES = "halo.run/old-theme-route-rules";
public static final String FINALIZER_NAME = "system-setting-protection";
private final ExtensionClient client;
private final ApplicationContext applicationContext;
private final RouteRuleReconciler routeRuleReconciler = new RouteRuleReconciler();
public SystemSettingReconciler(ExtensionClient client, ApplicationContext applicationContext) {
this.client = client;
this.applicationContext = applicationContext;
@ -39,110 +45,197 @@ public class SystemSettingReconciler implements Reconciler<Reconciler.Request> {
}
client.fetch(ConfigMap.class, name)
.ifPresent(configMap -> {
ConfigMap oldConfigMap = JsonUtils.deepCopy(configMap);
ruleChangedDispatcher(configMap);
if (!configMap.equals(oldConfigMap)) {
client.update(configMap);
}
addFinalizerIfNecessary(configMap);
routeRuleReconciler.reconcile(name);
});
return new Result(false, null);
}
private void ruleChangedDispatcher(ConfigMap configMap) {
Map<String, String> data = configMap.getData();
private void addFinalizerIfNecessary(ConfigMap oldConfigMap) {
Set<String> finalizers = oldConfigMap.getMetadata().getFinalizers();
if (finalizers != null && finalizers.contains(FINALIZER_NAME)) {
return;
}
client.fetch(ConfigMap.class, oldConfigMap.getMetadata().getName())
.ifPresent(configMap -> {
Set<String> newFinalizers = configMap.getMetadata().getFinalizers();
if (newFinalizers == null) {
newFinalizers = new HashSet<>();
configMap.getMetadata().setFinalizers(newFinalizers);
}
newFinalizers.add(FINALIZER_NAME);
client.update(configMap);
});
}
Map<String, String> annotations = getAnnotationsSafe(configMap);
String oldRulesJson = annotations.get(OLD_THEME_ROUTE_RULES);
class RouteRuleReconciler {
String routeRulesJson = data.get(SystemSetting.ThemeRouteRules.GROUP);
// get new rules and replace old rules to new rules
SystemSetting.ThemeRouteRules newRouteRules =
JsonUtils.jsonToObject(routeRulesJson, SystemSetting.ThemeRouteRules.class);
// old rules is empty, means this is the first time to update theme route rules
if (oldRulesJson == null) {
oldRulesJson = "{}";
public void reconcile(String name) {
reconcileArchivesRule(name);
reconcileTagsRule(name);
reconcileCategoriesRule(name);
reconcilePostRule(name);
}
// diff old rules and new rules
SystemSetting.ThemeRouteRules oldRules =
JsonUtils.jsonToObject(oldRulesJson, SystemSetting.ThemeRouteRules.class);
private void reconcileArchivesRule(String name) {
client.fetch(ConfigMap.class, name).ifPresent(configMap -> {
SystemSetting.ThemeRouteRules oldRules = getOldRouteRulesFromAnno(configMap);
SystemSetting.ThemeRouteRules newRules = getRouteRules(configMap);
// dispatch event
if (!StringUtils.equals(oldRules.getArchives(), newRouteRules.getArchives())) {
// archives rule changed
applicationContext.publishEvent(new PermalinkRuleChangedEvent(this,
DefaultTemplateEnum.ARCHIVES,
newRouteRules.getArchives()));
final String oldArchivesPrefix = oldRules.getArchives();
final String oldPostPattern = oldRules.getPost();
// dispatch event
final boolean archivesPrefixChanged =
!StringUtils.equals(oldRules.getArchives(), newRules.getArchives());
final boolean postPatternChanged =
changePostPatternPrefixIfNecessary(oldArchivesPrefix, newRules);
if (archivesPrefixChanged || postPatternChanged) {
oldRules.setPost(newRules.getPost());
oldRules.setArchives(newRules.getArchives());
updateNewRuleToConfigMap(configMap, oldRules, newRules);
}
// archives rule changed
if (archivesPrefixChanged) {
log.debug("Archives prefix changed from [{}] to [{}].", oldArchivesPrefix,
newRules.getArchives());
applicationContext.publishEvent(new PermalinkRuleChangedEvent(this,
DefaultTemplateEnum.ARCHIVES,
newRules.getArchives()));
}
if (postPatternChanged) {
log.debug("Post pattern changed from [{}] to [{}].", oldPostPattern,
newRules.getPost());
// post rule changed
applicationContext.publishEvent(new PermalinkRuleChangedEvent(this,
DefaultTemplateEnum.POST, newRules.getPost()));
}
});
}
if (!StringUtils.equals(oldRules.getTags(), newRouteRules.getTags())) {
// tags rule changed
applicationContext.publishEvent(new PermalinkRuleChangedEvent(this,
DefaultTemplateEnum.TAGS,
newRouteRules.getTags()));
}
if (!StringUtils.equals(oldRules.getCategories(), newRouteRules.getCategories())) {
// categories rule changed
applicationContext.publishEvent(new PermalinkRuleChangedEvent(this,
DefaultTemplateEnum.CATEGORIES,
newRouteRules.getCategories()));
}
if (!StringUtils.equals(oldRules.getPost(), newRouteRules.getPost())) {
// post rule changed
applicationContext.publishEvent(new PermalinkRuleChangedEvent(this,
DefaultTemplateEnum.POST,
newRouteRules.getPost()));
}
// TODO 此处立即同步 post 的新 pattern 到数据库,才能更新到文章页面的 permalink 地址
// 但会导致乐观锁失效会失败一次 reconcile
if (changePostPatternPrefixIfNecessary(oldRules, newRouteRules)) {
data.put(SystemSetting.ThemeRouteRules.GROUP, JsonUtils.objectToJson(newRouteRules));
annotations.put(OLD_THEME_ROUTE_RULES, JsonUtils.objectToJson(newRouteRules));
// update config map immediately
private void updateNewRuleToConfigMap(ConfigMap configMap,
SystemSetting.ThemeRouteRules oldRules,
SystemSetting.ThemeRouteRules newRules) {
Map<String, String> annotations = getAnnotationsSafe(configMap);
annotations.put(OLD_THEME_ROUTE_RULES, JsonUtils.objectToJson(oldRules));
configMap.getData().put(SystemSetting.ThemeRouteRules.GROUP,
JsonUtils.objectToJson(newRules));
client.update(configMap);
}
// update theme setting
data.put(SystemSetting.ThemeRouteRules.GROUP, JsonUtils.objectToJson(newRouteRules));
annotations.put(OLD_THEME_ROUTE_RULES, JsonUtils.objectToJson(newRouteRules));
}
private void reconcileTagsRule(String name) {
client.fetch(ConfigMap.class, name).ifPresent(configMap -> {
SystemSetting.ThemeRouteRules oldRules = getOldRouteRulesFromAnno(configMap);
SystemSetting.ThemeRouteRules newRules = getRouteRules(configMap);
final String oldTagsPrefix = oldRules.getTags();
if (!StringUtils.equals(oldTagsPrefix, newRules.getTags())) {
oldRules.setTags(newRules.getTags());
updateNewRuleToConfigMap(configMap, oldRules, newRules);
boolean changePostPatternPrefixIfNecessary(SystemSetting.ThemeRouteRules oldRules,
SystemSetting.ThemeRouteRules newRules) {
if (StringUtils.isBlank(oldRules.getArchives())
|| StringUtils.isBlank(newRules.getPost())) {
log.debug("Tags prefix changed from [{}] to [{}].", oldTagsPrefix,
newRules.getTags());
// then publish event
applicationContext.publishEvent(new PermalinkRuleChangedEvent(this,
DefaultTemplateEnum.TAGS,
newRules.getTags()));
}
});
}
private void reconcileCategoriesRule(String name) {
client.fetch(ConfigMap.class, name).ifPresent(configMap -> {
SystemSetting.ThemeRouteRules oldRules = getOldRouteRulesFromAnno(configMap);
SystemSetting.ThemeRouteRules newRules = getRouteRules(configMap);
final String oldCategoriesPrefix = oldRules.getCategories();
if (!StringUtils.equals(oldCategoriesPrefix, newRules.getCategories())) {
oldRules.setCategories(newRules.getCategories());
updateNewRuleToConfigMap(configMap, oldRules, newRules);
log.debug("Categories prefix changed from [{}] to [{}].", oldCategoriesPrefix,
newRules.getCategories());
// categories rule changed
applicationContext.publishEvent(new PermalinkRuleChangedEvent(this,
DefaultTemplateEnum.CATEGORIES,
newRules.getCategories()));
}
});
}
private void reconcilePostRule(String name) {
client.fetch(ConfigMap.class, name).ifPresent(configMap -> {
SystemSetting.ThemeRouteRules oldRules = getOldRouteRulesFromAnno(configMap);
SystemSetting.ThemeRouteRules newRules = getRouteRules(configMap);
final String oldPostPattern = oldRules.getPost();
if (!StringUtils.equals(oldPostPattern, newRules.getPost())) {
oldRules.setPost(newRules.getPost());
updateNewRuleToConfigMap(configMap, oldRules, newRules);
log.debug("Categories prefix changed from [{}] to [{}].", oldPostPattern,
newRules.getPost());
// post rule changed
applicationContext.publishEvent(new PermalinkRuleChangedEvent(this,
DefaultTemplateEnum.POST,
newRules.getPost()));
}
});
}
static boolean changePostPatternPrefixIfNecessary(String oldArchivePrefix,
SystemSetting.ThemeRouteRules newRules) {
if (StringUtils.isBlank(oldArchivePrefix)
|| StringUtils.isBlank(newRules.getPost())) {
return false;
}
String newArchivesPrefix = newRules.getArchives();
if (StringUtils.equals(oldArchivePrefix, newArchivesPrefix)) {
return false;
}
String oldPrefix = StringUtils.removeStart(oldArchivePrefix, "/");
String postPattern = StringUtils.removeStart(newRules.getPost(), "/");
if (postPattern.startsWith(oldPrefix)) {
String postPatternToUpdate = PathUtils.combinePath(newArchivesPrefix,
StringUtils.removeStart(postPattern, oldPrefix));
newRules.setPost(postPatternToUpdate);
return true;
}
return false;
}
String oldArchivesPrefix = StringUtils.removeStart(oldRules.getArchives(), "/");
String postPattern = StringUtils.removeStart(newRules.getPost(), "/");
String newArchivesPrefix = newRules.getArchives();
if (postPattern.startsWith(oldArchivesPrefix)) {
String postPatternToUpdate = PathUtils.combinePath(newArchivesPrefix,
StringUtils.removeStart(postPattern, oldArchivesPrefix));
newRules.setPost(postPatternToUpdate);
private SystemSetting.ThemeRouteRules getOldRouteRulesFromAnno(ConfigMap configMap) {
Map<String, String> annotations = getAnnotationsSafe(configMap);
String oldRulesJson = annotations.get(OLD_THEME_ROUTE_RULES);
// post rule changed
applicationContext.publishEvent(new PermalinkRuleChangedEvent(this,
DefaultTemplateEnum.POST, postPatternToUpdate));
return true;
// old rules is empty, means this is the first time to update theme route rules
if (oldRulesJson == null) {
oldRulesJson = "{}";
}
// diff old rules and new rules
return JsonUtils.jsonToObject(oldRulesJson, SystemSetting.ThemeRouteRules.class);
}
return false;
}
private Map<String, String> getAnnotationsSafe(ConfigMap configMap) {
Map<String, String> annotations = configMap.getMetadata().getAnnotations();
if (annotations == null) {
annotations = new HashMap<>();
configMap.getMetadata().setAnnotations(annotations);
private SystemSetting.ThemeRouteRules getRouteRules(ConfigMap configMap) {
Map<String, String> data = configMap.getData();
// get new rules and replace old rules to new rules
return JsonUtils.jsonToObject(data.get(SystemSetting.ThemeRouteRules.GROUP),
SystemSetting.ThemeRouteRules.class);
}
private Map<String, String> getAnnotationsSafe(ConfigMap configMap) {
Map<String, String> annotations = configMap.getMetadata().getAnnotations();
if (annotations == null) {
annotations = new HashMap<>();
configMap.getMetadata().setAnnotations(annotations);
}
return annotations;
}
return annotations;
}
public boolean isSystemSetting(String name) {

View File

@ -36,9 +36,13 @@ public class SystemConfigurableEnvironmentFetcher {
@NonNull
private Mono<Map<String, String>> getValuesInternal() {
return extensionClient.fetch(ConfigMap.class, SystemSetting.SYSTEM_CONFIG)
return getConfigMap()
.filter(configMap -> configMap.getData() != null)
.map(ConfigMap::getData)
.defaultIfEmpty(Map.of());
}
public Mono<ConfigMap> getConfigMap() {
return extensionClient.fetch(ConfigMap.class, SystemSetting.SYSTEM_CONFIG);
}
}

View File

@ -38,4 +38,44 @@ public class SystemSetting {
private String footer;
}
@Data
public static class Basic {
public static final String GROUP = "basic";
String title;
String subtitle;
String logo;
String favicon;
}
@Data
public static class User {
public static final String GROUP = "user";
Boolean allowRegistration;
String defaultRole;
}
@Data
public static class Post {
public static final String GROUP = "post";
String sortOrder;
Integer pageSize;
Boolean review;
}
@Data
public static class Seo {
public static final String GROUP = "seo";
Boolean blockSpiders;
String keywords;
String description;
}
@Data
public static class Comment {
public static final String GROUP = "comment";
Boolean enable;
Boolean requireReviewForNew;
Boolean systemUserOnly;
}
}

View File

@ -1,14 +1,19 @@
package run.halo.app.theme;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.http.MediaType;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.result.view.View;
import org.springframework.web.server.ServerWebExchange;
import org.thymeleaf.spring6.view.reactive.ThymeleafReactiveView;
import org.thymeleaf.spring6.view.reactive.ThymeleafReactiveViewResolver;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import run.halo.app.theme.finders.FinderRegistry;
@ -49,9 +54,51 @@ public class HaloViewResolver extends ThymeleafReactiveViewResolver {
// calculate the engine before rendering
setTemplateEngine(engineManager.getTemplateEngine(theme));
return super.render(model, contentType, exchange)
.subscribeOn(Schedulers.boundedElastic());
.subscribeOn(Schedulers.boundedElastic());
});
}
}
@Override
@NonNull
protected Mono<Map<String, Object>> getModelAttributes(Map<String, ?> model,
@NonNull ServerWebExchange exchange) {
Mono<Map<String, Object>> contextBasedStaticVariables =
getContextBasedStaticVariables(exchange);
Mono<Map<String, Object>> modelAttributes = super.getModelAttributes(model, exchange);
return Flux.merge(modelAttributes, contextBasedStaticVariables)
.collectList()
.map(modelMapList -> {
Map<String, Object> result = new HashMap<>();
modelMapList.forEach(result::putAll);
return result;
});
}
@NonNull
private Mono<Map<String, Object>> getContextBasedStaticVariables(
ServerWebExchange exchange) {
ApplicationContext applicationContext = obtainApplicationContext();
return Mono.just(new HashMap<String, Object>())
.flatMap(staticVariables -> {
List<Mono<Map<String, Object>>> monoList = applicationContext.getBeansOfType(
ViewContextBasedVariablesAcquirer.class)
.values()
.stream()
.map(acquirer -> acquirer.acquire(exchange))
.toList();
return Flux.merge(monoList)
.collectList()
.map(modelList -> {
Map<String, Object> mergedModel = new HashMap<>();
modelList.forEach(mergedModel::putAll);
return mergedModel;
})
.map(mergedModel -> {
staticVariables.putAll(mergedModel);
return staticVariables;
});
});
}
}
}

View File

@ -0,0 +1,34 @@
package run.halo.app.theme;
import java.util.Map;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
import run.halo.app.theme.finders.vo.SiteSettingVo;
/**
* Site setting variables acquirer.
*
* @author guqing
* @since 2.0.0
*/
@Component
public class SiteSettingVariablesAcquirer implements ViewContextBasedVariablesAcquirer {
private final SystemConfigurableEnvironmentFetcher environmentFetcher;
public SiteSettingVariablesAcquirer(SystemConfigurableEnvironmentFetcher environmentFetcher) {
this.environmentFetcher = environmentFetcher;
}
@Override
public Mono<Map<String, Object>> acquire(ServerWebExchange exchange) {
return environmentFetcher.getConfigMap()
.filter(configMap -> configMap.getData() != null)
.map(configMap -> {
SiteSettingVo siteSettingVo = SiteSettingVo.from(configMap);
return Map.of("site", siteSettingVo);
});
}
}

View File

@ -0,0 +1,41 @@
package run.halo.app.theme;
import java.util.Map;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import run.halo.app.theme.finders.ThemeFinder;
import run.halo.app.theme.finders.vo.ThemeVo;
/**
* Theme context based variables acquirer.
*
* @author guqing
* @since 2.0.0
*/
@Component
public class ThemeContextBasedVariablesAcquirer implements ViewContextBasedVariablesAcquirer {
private final ThemeFinder themeFinder;
private final ThemeResolver themeResolver;
public ThemeContextBasedVariablesAcquirer(ThemeFinder themeFinder,
ThemeResolver themeResolver) {
this.themeFinder = themeFinder;
this.themeResolver = themeResolver;
}
@Override
public Mono<Map<String, Object>> acquire(ServerWebExchange exchange) {
return themeResolver.getTheme(exchange.getRequest())
.publishOn(Schedulers.boundedElastic())
.map(themeContext -> {
String name = themeContext.getName();
ThemeVo themeVo = themeFinder.getByName(name);
if (themeVo == null) {
return Map.of();
}
return Map.of("theme", themeVo);
});
}
}

View File

@ -0,0 +1,11 @@
package run.halo.app.theme;
import java.util.Map;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
@FunctionalInterface
public interface ViewContextBasedVariablesAcquirer {
Mono<Map<String, Object>> acquire(ServerWebExchange exchange);
}

View File

@ -13,7 +13,7 @@ import org.thymeleaf.standard.StandardDialect;
* @since 2.0.0
*/
public class HaloProcessorDialect extends AbstractProcessorDialect {
private static final String DIALECT_NAME = "Halo Theme Dialect";
private static final String DIALECT_NAME = "haloThemeProcessorDialect";
public HaloProcessorDialect() {
// We will set this dialect the same "dialect processor" precedence as
@ -27,6 +27,7 @@ public class HaloProcessorDialect extends AbstractProcessorDialect {
// add more processors
processors.add(new GlobalHeadInjectionProcessor(dialectPrefix));
processors.add(new TemplateFooterElementTagProcessor(dialectPrefix));
processors.add(new JsonNodePropertyAccessorBoundariesProcessor());
return processors;
}
}

View File

@ -0,0 +1,45 @@
package run.halo.app.theme.dialect;
import org.springframework.integration.json.JsonPropertyAccessor;
import org.thymeleaf.context.ITemplateContext;
import org.thymeleaf.model.ITemplateEnd;
import org.thymeleaf.model.ITemplateStart;
import org.thymeleaf.processor.templateboundaries.AbstractTemplateBoundariesProcessor;
import org.thymeleaf.processor.templateboundaries.ITemplateBoundariesStructureHandler;
import org.thymeleaf.spring6.expression.ThymeleafEvaluationContext;
import org.thymeleaf.standard.StandardDialect;
import org.thymeleaf.templatemode.TemplateMode;
/**
* A template boundaries processor for add {@link JsonPropertyAccessor} to
* {@link ThymeleafEvaluationContext}.
*
* @author guqing
* @since 2.0.0
*/
public class JsonNodePropertyAccessorBoundariesProcessor
extends AbstractTemplateBoundariesProcessor {
private static final int PRECEDENCE = StandardDialect.PROCESSOR_PRECEDENCE;
private static final JsonPropertyAccessor JSON_PROPERTY_ACCESSOR = new JsonPropertyAccessor();
public JsonNodePropertyAccessorBoundariesProcessor() {
super(TemplateMode.HTML, PRECEDENCE);
}
@Override
public void doProcessTemplateStart(ITemplateContext context, ITemplateStart templateStart,
ITemplateBoundariesStructureHandler structureHandler) {
ThymeleafEvaluationContext evaluationContext =
(ThymeleafEvaluationContext) context.getVariable(
ThymeleafEvaluationContext.THYMELEAF_EVALUATION_CONTEXT_CONTEXT_VARIABLE_NAME);
if (evaluationContext != null) {
evaluationContext.addPropertyAccessor(JSON_PROPERTY_ACCESSOR);
}
}
@Override
public void doProcessTemplateEnd(ITemplateContext context, ITemplateEnd templateEnd,
ITemplateBoundariesStructureHandler structureHandler) {
// nothing to do
}
}

View File

@ -0,0 +1,16 @@
package run.halo.app.theme.finders;
import run.halo.app.theme.finders.vo.ThemeVo;
/**
* A finder for theme.
*
* @author guqing
* @since 2.0.0
*/
public interface ThemeFinder {
ThemeVo activation();
ThemeVo getByName(String themeName);
}

View File

@ -0,0 +1,68 @@
package run.halo.app.theme.finders.impl;
import com.fasterxml.jackson.databind.JsonNode;
import java.util.HashMap;
import java.util.Map;
import org.apache.commons.lang3.StringUtils;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.Theme;
import run.halo.app.extension.ConfigMap;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
import run.halo.app.infra.SystemSetting;
import run.halo.app.infra.utils.JsonUtils;
import run.halo.app.theme.finders.Finder;
import run.halo.app.theme.finders.ThemeFinder;
import run.halo.app.theme.finders.vo.ThemeVo;
/**
* A default implementation for {@link ThemeFinder}.
*
* @author guqing
* @since 2.0.0
*/
@Finder("themeFinder")
public class ThemeFinderImpl implements ThemeFinder {
private final ReactiveExtensionClient client;
private final SystemConfigurableEnvironmentFetcher environmentFetcher;
public ThemeFinderImpl(ReactiveExtensionClient client,
SystemConfigurableEnvironmentFetcher environmentFetcher) {
this.client = client;
this.environmentFetcher = environmentFetcher;
}
@Override
public ThemeVo activation() {
return environmentFetcher.fetch(SystemSetting.Theme.GROUP, SystemSetting.Theme.class)
.map(SystemSetting.Theme::getActive)
.flatMap(themeName -> client.fetch(Theme.class, themeName))
.flatMap(theme -> themeWithConfig(ThemeVo.from(theme)))
.block();
}
@Override
public ThemeVo getByName(String themeName) {
return client.fetch(Theme.class, themeName)
.flatMap(theme -> themeWithConfig(ThemeVo.from(theme)))
.block();
}
private Mono<ThemeVo> themeWithConfig(ThemeVo themeVo) {
if (StringUtils.isBlank(themeVo.getSpec().getConfigMapName())) {
return Mono.just(themeVo);
}
return client.fetch(ConfigMap.class, themeVo.getSpec().getConfigMapName())
.map(configMap -> {
Map<String, JsonNode> config = new HashMap<>();
configMap.getData().forEach((k, v) -> {
JsonNode jsonNode = JsonUtils.jsonToObject(v, JsonNode.class);
config.put(k, jsonNode);
});
JsonNode configJson = JsonUtils.mapToObject(config, JsonNode.class);
return themeVo.withConfig(configJson);
})
.switchIfEmpty(Mono.just(themeVo));
}
}

View File

@ -0,0 +1,121 @@
package run.halo.app.theme.finders.vo;
import java.util.Map;
import lombok.Builder;
import lombok.Value;
import org.springframework.util.Assert;
import run.halo.app.extension.ConfigMap;
import run.halo.app.infra.SystemSetting;
import run.halo.app.infra.utils.JsonUtils;
/**
* Site setting value object for theme.
*
* @author guqing
* @since 2.0.0
*/
@Value
@Builder
public class SiteSettingVo {
String title;
String subtitle;
String logo;
String favicon;
Boolean allowRegistration;
PostSetting post;
SeoSetting seo;
CommentSetting comment;
/**
* Convert to system {@link ConfigMap} to {@link SiteSettingVo}.
*
* @param configMap config map named system
* @return site setting value object
*/
public static SiteSettingVo from(ConfigMap configMap) {
Assert.notNull(configMap, "The configMap must not be null.");
Map<String, String> data = configMap.getData();
if (data == null) {
return builder().build();
}
SystemSetting.Basic basicSetting =
toObject(data.get(SystemSetting.Basic.GROUP), SystemSetting.Basic.class);
SystemSetting.User userSetting =
toObject(data.get(SystemSetting.User.GROUP), SystemSetting.User.class);
SystemSetting.Post postSetting =
toObject(data.get(SystemSetting.Post.GROUP), SystemSetting.Post.class);
SystemSetting.Seo seoSetting =
toObject(data.get(SystemSetting.Seo.GROUP), SystemSetting.Seo.class);
SystemSetting.Comment commentSetting = toObject(data.get(SystemSetting.Comment.GROUP),
SystemSetting.Comment.class);
return builder()
.title(basicSetting.getTitle())
.subtitle(basicSetting.getSubtitle())
.logo(basicSetting.getLogo())
.favicon(basicSetting.getFavicon())
.allowRegistration(userSetting.getAllowRegistration())
.post(PostSetting.builder()
.sortOrder(postSetting.getSortOrder())
.pageSize(postSetting.getPageSize())
.build())
.seo(SeoSetting.builder()
.blockSpiders(seoSetting.getBlockSpiders())
.keywords(seoSetting.getKeywords())
.description(seoSetting.getDescription())
.build())
.comment(CommentSetting.builder()
.enable(commentSetting.getEnable())
.requireReviewForNew(commentSetting.getRequireReviewForNew())
.systemUserOnly(commentSetting.getSystemUserOnly())
.build())
.build();
}
private static <T> T toObject(String json, Class<T> type) {
if (json == null) {
// empty object
json = "{}";
}
return JsonUtils.jsonToObject(json, type);
}
@Value
@Builder
public static class PostSetting {
String sortOrder;
Integer pageSize;
}
@Value
@Builder
public static class SeoSetting {
Boolean blockSpiders;
String keywords;
String description;
}
@Value
@Builder
public static class CommentSetting {
Boolean enable;
Boolean systemUserOnly;
Boolean requireReviewForNew;
}
}

View File

@ -0,0 +1,42 @@
package run.halo.app.theme.finders.vo;
import com.fasterxml.jackson.databind.JsonNode;
import lombok.Builder;
import lombok.ToString;
import lombok.Value;
import lombok.With;
import run.halo.app.core.extension.Theme;
import run.halo.app.extension.MetadataOperator;
/**
* A value object for {@link Theme}.
*
* @author guqing
* @since 2.0.0
*/
@Value
@Builder
@ToString
public class ThemeVo {
MetadataOperator metadata;
Theme.ThemeSpec spec;
@With
JsonNode config;
/**
* Convert {@link Theme} to {@link ThemeVo}.
*
* @param theme theme extension
* @return theme value object
*/
public static ThemeVo from(Theme theme) {
return ThemeVo.builder()
.metadata(theme.getMetadata())
.spec(theme.getSpec())
.config(null)
.build();
}
}

View File

@ -91,7 +91,7 @@ spec:
label: SEO 设置
formSchema:
- $formkit: checkbox
name: blockSpides
name: blockSpiders
label: "屏蔽搜索引擎"
value: false
- $formkit: textarea

View File

@ -1,15 +1,30 @@
package run.halo.app.core.extension.reconciler;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.Function;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.context.ApplicationContext;
import run.halo.app.extension.ConfigMap;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.controller.Reconciler;
import run.halo.app.infra.SystemSetting;
import run.halo.app.infra.utils.JsonUtils;
/**
* Tests for {@link SystemSettingReconciler}.
@ -34,16 +49,133 @@ class SystemSettingReconcilerTest {
}
@Test
void changePostPatternPrefixIfNecessary() {
SystemSetting.ThemeRouteRules oldRouteRules = new SystemSetting.ThemeRouteRules();
oldRouteRules.setPost("/archives/{slug}");
oldRouteRules.setArchives("archives");
void reconcileArchivesRouteRule() {
ConfigMap configMap = systemConfigMapForRouteRule(rules -> {
rules.setArchives("archives-new");
return rules;
});
when(client.fetch(eq(ConfigMap.class), eq(SystemSetting.SYSTEM_CONFIG)))
.thenReturn(Optional.of(configMap));
systemSettingReconciler.reconcile(new Reconciler.Request(SystemSetting.SYSTEM_CONFIG));
ArgumentCaptor<ConfigMap> captor = ArgumentCaptor.forClass(ConfigMap.class);
verify(client, times(2)).update(captor.capture());
List<ConfigMap> allValues = captor.getAllValues();
ConfigMap updatedConfigMap = allValues.get(1);
assertThat(rulesFrom(updatedConfigMap).getArchives()).isEqualTo("archives-new");
assertThat(rulesFrom(updatedConfigMap).getPost()).isEqualTo("/archives-new/{slug}");
assertThat(oldRulesFromAnno(updatedConfigMap).getArchives()).isEqualTo("archives-new");
assertThat(oldRulesFromAnno(updatedConfigMap).getPost()).isEqualTo("/archives-new/{slug}");
// archives and post
verify(applicationContext, times(2)).publishEvent(any());
}
@Test
void reconcileTagsRule() {
ConfigMap configMap = systemConfigMapForRouteRule(rules -> {
rules.setTags("tags-new");
return rules;
});
when(client.fetch(eq(ConfigMap.class), eq(SystemSetting.SYSTEM_CONFIG)))
.thenReturn(Optional.of(configMap));
systemSettingReconciler.reconcile(new Reconciler.Request(SystemSetting.SYSTEM_CONFIG));
ArgumentCaptor<ConfigMap> captor = ArgumentCaptor.forClass(ConfigMap.class);
verify(client, times(2)).update(captor.capture());
List<ConfigMap> allValues = captor.getAllValues();
ConfigMap updatedConfigMap = allValues.get(1);
assertThat(rulesFrom(updatedConfigMap).getTags()).isEqualTo("tags-new");
assertThat(oldRulesFromAnno(updatedConfigMap).getTags()).isEqualTo("tags-new");
verify(applicationContext, times(1)).publishEvent(any());
}
@Test
void reconcileCategoriesRule() {
ConfigMap configMap = systemConfigMapForRouteRule(rules -> {
rules.setCategories("categories-new");
return rules;
});
when(client.fetch(eq(ConfigMap.class), eq(SystemSetting.SYSTEM_CONFIG)))
.thenReturn(Optional.of(configMap));
systemSettingReconciler.reconcile(new Reconciler.Request(SystemSetting.SYSTEM_CONFIG));
ArgumentCaptor<ConfigMap> captor = ArgumentCaptor.forClass(ConfigMap.class);
verify(client, times(2)).update(captor.capture());
List<ConfigMap> allValues = captor.getAllValues();
ConfigMap updatedConfigMap = allValues.get(1);
assertThat(rulesFrom(updatedConfigMap).getCategories()).isEqualTo("categories-new");
assertThat(oldRulesFromAnno(updatedConfigMap).getCategories()).isEqualTo("categories-new");
verify(applicationContext, times(1)).publishEvent(any());
}
@Test
void reconcilePostRule() {
ConfigMap configMap = systemConfigMapForRouteRule(rules -> {
rules.setPost("/post-new/{slug}");
return rules;
});
when(client.fetch(eq(ConfigMap.class), eq(SystemSetting.SYSTEM_CONFIG)))
.thenReturn(Optional.of(configMap));
systemSettingReconciler.reconcile(new Reconciler.Request(SystemSetting.SYSTEM_CONFIG));
ArgumentCaptor<ConfigMap> captor = ArgumentCaptor.forClass(ConfigMap.class);
verify(client, times(2)).update(captor.capture());
List<ConfigMap> allValues = captor.getAllValues();
ConfigMap updatedConfigMap = allValues.get(1);
assertThat(rulesFrom(updatedConfigMap).getPost()).isEqualTo("/post-new/{slug}");
assertThat(oldRulesFromAnno(updatedConfigMap).getPost()).isEqualTo("/post-new/{slug}");
verify(applicationContext, times(1)).publishEvent(any());
}
private SystemSetting.ThemeRouteRules rulesFrom(ConfigMap configMap) {
String s = configMap.getData().get(SystemSetting.ThemeRouteRules.GROUP);
return JsonUtils.jsonToObject(s, SystemSetting.ThemeRouteRules.class);
}
private SystemSetting.ThemeRouteRules oldRulesFromAnno(ConfigMap configMap) {
Map<String, String> annotations = configMap.getMetadata().getAnnotations();
String s = annotations.get(SystemSettingReconciler.OLD_THEME_ROUTE_RULES);
return JsonUtils.jsonToObject(s, SystemSetting.ThemeRouteRules.class);
}
private ConfigMap systemConfigMapForRouteRule(
Function<SystemSetting.ThemeRouteRules, SystemSetting.ThemeRouteRules> function) {
ConfigMap configMap = new ConfigMap();
Metadata metadata = new Metadata();
metadata.setName(SystemSetting.SYSTEM_CONFIG);
configMap.setMetadata(metadata);
SystemSetting.ThemeRouteRules themeRouteRules = new SystemSetting.ThemeRouteRules();
themeRouteRules.setArchives("archives");
themeRouteRules.setTags("tags");
themeRouteRules.setCategories("categories");
themeRouteRules.setPost("/archives/{slug}");
Map<String, String> annotations = new HashMap<>();
annotations.put(SystemSettingReconciler.OLD_THEME_ROUTE_RULES,
JsonUtils.objectToJson(themeRouteRules));
metadata.setAnnotations(annotations);
SystemSetting.ThemeRouteRules newRules = function.apply(themeRouteRules);
configMap.putDataItem(SystemSetting.ThemeRouteRules.GROUP,
JsonUtils.objectToJson(newRules));
return configMap;
}
@Test
void changePostPatternPrefixIfNecessary() {
SystemSetting.ThemeRouteRules newRouteRules = new SystemSetting.ThemeRouteRules();
newRouteRules.setPost("/archives/{slug}");
newRouteRules.setArchives("new");
boolean result = systemSettingReconciler.changePostPatternPrefixIfNecessary(oldRouteRules,
newRouteRules);
boolean result = SystemSettingReconciler.RouteRuleReconciler
.changePostPatternPrefixIfNecessary("archives", newRouteRules);
assertThat(result).isTrue();
assertThat(newRouteRules.getPost()).isEqualTo("/new/{slug}");

View File

@ -13,7 +13,6 @@ import java.time.ZoneId;
import java.util.Locale;
import java.util.Map;
import java.util.TimeZone;
import java.util.function.Function;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@ -53,8 +52,6 @@ class ThemeJava8TimeDialectIntegrationTest {
private URL defaultThemeUrl;
Function<ServerHttpRequest, ThemeContext> themeContextFunction;
@Autowired
private WebTestClient webTestClient;
@ -63,8 +60,8 @@ class ThemeJava8TimeDialectIntegrationTest {
@BeforeEach
void setUp() throws FileNotFoundException {
defaultThemeUrl = ResourceUtils.getURL("classpath:themes/default");
when(themeResolver.getTheme(any(ServerHttpRequest.class))).thenReturn(
Mono.just(createDefaultContext()));
when(themeResolver.getTheme(any(ServerHttpRequest.class)))
.thenReturn(Mono.just(createDefaultContext()));
defaultTimeZone = TimeZone.getDefault();
}