mirror of https://github.com/halo-dev/halo
feat: specific implementation of theme design (#2280)
<!-- Thanks for sending a pull request! Here are some tips for you: 1. 如果这是你的第一次,请阅读我们的贡献指南:<https://github.com/halo-dev/halo/blob/master/CONTRIBUTING.md>。 1. If this is your first time, please read our contributor guidelines: <https://github.com/halo-dev/halo/blob/master/CONTRIBUTING.md>. 2. 请根据你解决问题的类型为 Pull Request 添加合适的标签。 2. Please label this pull request according to what type of issue you are addressing, especially if this is a release targeted pull request. 3. 请确保你已经添加并运行了适当的测试。 3. Ensure you have added or ran the appropriate tests for your PR. --> #### What type of PR is this? /kind feature /area core /milestone 2.0 <!-- 添加其中一个类别: Add one of the following kinds: /kind bug /kind cleanup /kind documentation /kind feature /kind improvement 适当添加其中一个或多个类别(可选): Optionally add one or more of the following kinds if applicable: /kind api-change /kind deprecation /kind failing-test /kind flake /kind regression --> #### What this PR does / why we need it: 主题设计的具体实现 https://github.com/halo-dev/rfcs/tree/main/theme 1. 主题支持多语言,在主题目录的 i18n目录下 2. 主题支持预览,但暂未添加是否开启预览的限制 3. 主题及语言文件默认支持缓存,但暂未支持是否关闭缓存选项 4. 主题名称与主题目录必须一致 5. 主题可以通过添加参数 language 来切换语言,例如 /post?language=en ```text ├── i18n │ └── default.properties │ └── en.properties ├── templates │ └── assets ├── css │ └── style.css ├── js │ └── main.js │ └── index.html ├── README.md └── settings.yaml └── theme.yaml ``` #### Which issue(s) this PR fixes: <!-- PR 合并时自动关闭 issue。 Automatically closes linked issue when PR is merged. 用法:`Fixes #<issue 号>`,或者 `Fixes (粘贴 issue 完整链接)` Usage: `Fixes #<issue number>`, or `Fixes (paste link of issue)`. --> Fixes # #### Special notes for your reviewer: /cc @halo-dev/sig-halo #### Does this PR introduce a user-facing change? <!-- 如果当前 Pull Request 的修改不会造成用户侧的任何变更,在 `release-note` 代码块儿中填写 `NONE`。 否则请填写用户侧能够理解的 Release Note。如果当前 Pull Request 包含破坏性更新(Break Change), Release Note 需要以 `action required` 开头。 If no, just write "NONE" in the release-note block below. If yes, a release note is required: Enter your extended release note in the block below. If the PR requires additional action from users switching to the new release, include the string "action required". --> ```release-note None ```pull/2300/head^2
parent
bd6c2a544b
commit
3302ce68c9
|
@ -14,5 +14,4 @@ public class HaloConfiguration {
|
|||
builder.serializationInclusion(JsonInclude.Include.NON_NULL);
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
}
|
|
@ -11,7 +11,6 @@ import org.springframework.http.codec.ServerCodecConfigurer;
|
|||
import org.springframework.http.codec.json.Jackson2JsonDecoder;
|
||||
import org.springframework.http.codec.json.Jackson2JsonEncoder;
|
||||
import org.springframework.lang.NonNull;
|
||||
import org.springframework.web.reactive.config.EnableWebFlux;
|
||||
import org.springframework.web.reactive.config.WebFluxConfigurer;
|
||||
import org.springframework.web.reactive.function.server.RouterFunction;
|
||||
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||
|
@ -21,7 +20,6 @@ import run.halo.app.core.extension.endpoint.CustomEndpoint;
|
|||
import run.halo.app.core.extension.endpoint.CustomEndpointsBuilder;
|
||||
|
||||
@Configuration
|
||||
@EnableWebFlux
|
||||
public class WebFluxConfig implements WebFluxConfigurer {
|
||||
|
||||
final ObjectMapper objectMapper;
|
||||
|
@ -64,4 +62,5 @@ public class WebFluxConfig implements WebFluxConfigurer {
|
|||
.forEach(customEndpoint -> builder.add(customEndpoint.endpoint()));
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -27,7 +27,6 @@ import org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder;
|
|||
import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder;
|
||||
import org.springframework.security.oauth2.jwt.SupplierReactiveJwtDecoder;
|
||||
import org.springframework.security.web.server.SecurityWebFilterChain;
|
||||
import org.springframework.security.web.server.csrf.CookieServerCsrfTokenRepository;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.reactive.CorsConfigurationSource;
|
||||
import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource;
|
||||
|
@ -66,10 +65,12 @@ public class WebServerSecurityConfig {
|
|||
RoleService roleService) {
|
||||
http.csrf().disable()
|
||||
.cors(corsSpec -> corsSpec.configurationSource(apiCorsConfigurationSource()))
|
||||
.securityMatcher(pathMatchers("/api/**", "/apis/**"))
|
||||
.securityMatcher(pathMatchers("/api/**", "/apis/**", "/login", "/logout"))
|
||||
.authorizeExchange(exchanges ->
|
||||
exchanges.anyExchange().access(new RequestInfoAuthorizationManager(roleService)))
|
||||
.httpBasic(withDefaults())
|
||||
.formLogin(withDefaults())
|
||||
.logout(withDefaults())
|
||||
// for reuse the JWT authentication
|
||||
.oauth2ResourceServer().jwt();
|
||||
|
||||
|
@ -86,24 +87,6 @@ public class WebServerSecurityConfig {
|
|||
return http.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
@Order(0)
|
||||
SecurityWebFilterChain webFilterChain(ServerHttpSecurity http) {
|
||||
http.authorizeExchange(exchanges -> exchanges.pathMatchers(
|
||||
"/actuator/**",
|
||||
"/swagger-ui.html", "/webjars/**", "/v3/api-docs/**"
|
||||
).permitAll())
|
||||
.cors(corsSpec -> corsSpec.configurationSource(apiCorsConfigurationSource()))
|
||||
.authorizeExchange(exchanges -> exchanges.anyExchange().authenticated())
|
||||
.cors(withDefaults())
|
||||
.httpBasic(withDefaults())
|
||||
.formLogin(withDefaults())
|
||||
.csrf().csrfTokenRepository(CookieServerCsrfTokenRepository.withHttpOnlyFalse()).and()
|
||||
.logout(withDefaults());
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
CorsConfigurationSource apiCorsConfigurationSource() {
|
||||
CorsConfiguration configuration = new CorsConfiguration();
|
||||
configuration.setAllowedOriginPatterns(List.of("*"));
|
||||
|
|
|
@ -0,0 +1,56 @@
|
|||
package run.halo.app.core.extension;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.ToString;
|
||||
import run.halo.app.extension.AbstractExtension;
|
||||
import run.halo.app.extension.GVK;
|
||||
|
||||
/**
|
||||
* @author guqing
|
||||
* @since 2.0.0
|
||||
*/
|
||||
@Data
|
||||
@ToString(callSuper = true)
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@GVK(group = "theme.halo.run", version = "v1alpha1", kind = "Theme",
|
||||
plural = "themes", singular = "theme")
|
||||
public class Theme extends AbstractExtension {
|
||||
|
||||
@Schema(required = true)
|
||||
private ThemeSpec spec;
|
||||
|
||||
@Data
|
||||
@ToString
|
||||
public static class ThemeSpec {
|
||||
|
||||
@Schema(required = true, minLength = 1)
|
||||
private String displayName;
|
||||
|
||||
@Schema(required = true)
|
||||
private Author author;
|
||||
|
||||
private String description;
|
||||
|
||||
private String logo;
|
||||
|
||||
private String website;
|
||||
|
||||
private String repo;
|
||||
|
||||
private String version = "*";
|
||||
|
||||
private String require = "*";
|
||||
}
|
||||
|
||||
@Data
|
||||
@ToString
|
||||
public static class Author {
|
||||
|
||||
@Schema(required = true, minLength = 1)
|
||||
private String name;
|
||||
|
||||
private String website;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
package run.halo.app.infra;
|
||||
|
||||
/**
|
||||
* Not found exception.
|
||||
*
|
||||
* @author guqing
|
||||
* @since 2.0.0
|
||||
*/
|
||||
public class NotFoundException extends RuntimeException {
|
||||
public NotFoundException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public NotFoundException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
|
@ -9,6 +9,7 @@ import run.halo.app.core.extension.ReverseProxy;
|
|||
import run.halo.app.core.extension.Role;
|
||||
import run.halo.app.core.extension.RoleBinding;
|
||||
import run.halo.app.core.extension.Setting;
|
||||
import run.halo.app.core.extension.Theme;
|
||||
import run.halo.app.core.extension.User;
|
||||
import run.halo.app.extension.ConfigMap;
|
||||
import run.halo.app.extension.SchemeManager;
|
||||
|
@ -33,5 +34,6 @@ public class SchemeInitializer implements ApplicationListener<ApplicationStarted
|
|||
schemeManager.register(ReverseProxy.class);
|
||||
schemeManager.register(Setting.class);
|
||||
schemeManager.register(ConfigMap.class);
|
||||
schemeManager.register(Theme.class);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,51 @@
|
|||
package run.halo.app.infra;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import org.springframework.core.convert.ConversionService;
|
||||
import org.springframework.lang.NonNull;
|
||||
import org.springframework.stereotype.Component;
|
||||
import run.halo.app.extension.ConfigMap;
|
||||
import run.halo.app.extension.ExtensionClient;
|
||||
import run.halo.app.infra.utils.JsonUtils;
|
||||
|
||||
/**
|
||||
* @author guqing
|
||||
* @since 2.0.0
|
||||
*/
|
||||
@Component
|
||||
public class SystemConfigurableEnvironmentFetcher {
|
||||
private static final String SYSTEM_CONFIGMAP_NAME = "system";
|
||||
|
||||
private final ExtensionClient extensionClient;
|
||||
private final ConversionService conversionService;
|
||||
|
||||
public SystemConfigurableEnvironmentFetcher(ExtensionClient extensionClient,
|
||||
ConversionService conversionService) {
|
||||
this.extensionClient = extensionClient;
|
||||
this.conversionService = conversionService;
|
||||
}
|
||||
|
||||
public <T> Optional<T> fetch(String key, Class<T> type) {
|
||||
var stringValue = getInternal(key);
|
||||
if (stringValue == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
if (conversionService.canConvert(String.class, type)) {
|
||||
return Optional.ofNullable(conversionService.convert(stringValue, type));
|
||||
}
|
||||
return Optional.of(JsonUtils.jsonToObject(stringValue, type));
|
||||
}
|
||||
|
||||
private String getInternal(String group) {
|
||||
return getValuesInternal().get(group);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
private Map<String, String> getValuesInternal() {
|
||||
return extensionClient.fetch(ConfigMap.class, SYSTEM_CONFIGMAP_NAME)
|
||||
.filter(configMap -> configMap.getData() != null)
|
||||
.map(ConfigMap::getData)
|
||||
.orElse(Map.of());
|
||||
}
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
package run.halo.app.infra;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* TODO Optimization value acquisition.
|
||||
*
|
||||
* @author guqing
|
||||
* @since 2.0.0
|
||||
*/
|
||||
public class SystemSetting {
|
||||
|
||||
@Data
|
||||
public static class Theme {
|
||||
public static final String GROUP = "theme";
|
||||
|
||||
private String active;
|
||||
}
|
||||
}
|
|
@ -28,5 +28,4 @@ public class HaloProperties {
|
|||
private final ExtensionProperties extension = new ExtensionProperties();
|
||||
|
||||
private final SecurityProperties security = new SecurityProperties();
|
||||
|
||||
}
|
||||
|
|
|
@ -0,0 +1,25 @@
|
|||
package run.halo.app.infra.utils;
|
||||
|
||||
import java.nio.file.FileSystem;
|
||||
import java.nio.file.FileSystems;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import org.thymeleaf.util.StringUtils;
|
||||
|
||||
/**
|
||||
* File system path utils.
|
||||
*
|
||||
* @author guqing
|
||||
* @since 2.0.0
|
||||
*/
|
||||
public class FilePathUtils {
|
||||
private FilePathUtils() {
|
||||
}
|
||||
|
||||
public static Path combinePath(String first, String... more) {
|
||||
FileSystem fileSystem = FileSystems.getDefault();
|
||||
Path path = fileSystem.getPath(first, more);
|
||||
String unixPath = StringUtils.replace(path.normalize(), "\\", "/");
|
||||
return Paths.get(unixPath);
|
||||
}
|
||||
}
|
|
@ -4,7 +4,7 @@ import lombok.experimental.UtilityClass;
|
|||
import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
/**
|
||||
* Path manipulation tool class.
|
||||
* Http path manipulation tool class.
|
||||
*
|
||||
* @author guqing
|
||||
* @since 2.0.0
|
||||
|
@ -21,6 +21,9 @@ public class PathUtils {
|
|||
public static String combinePath(String... pathSegments) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (String path : pathSegments) {
|
||||
if (path == null) {
|
||||
continue;
|
||||
}
|
||||
String s = path.startsWith("/") ? path : "/" + path;
|
||||
String segment = s.endsWith("/") ? s.substring(0, s.length() - 1) : s;
|
||||
sb.append(segment);
|
||||
|
|
|
@ -0,0 +1,45 @@
|
|||
package run.halo.app.theme;
|
||||
|
||||
import java.util.Map;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.server.ServerWebExchange;
|
||||
import org.thymeleaf.spring6.ISpringWebFluxTemplateEngine;
|
||||
import org.thymeleaf.spring6.view.reactive.ThymeleafReactiveView;
|
||||
import org.thymeleaf.spring6.view.reactive.ThymeleafReactiveViewResolver;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@Component("thymeleafReactiveViewResolver")
|
||||
public class HaloViewResolver extends ThymeleafReactiveViewResolver {
|
||||
|
||||
public HaloViewResolver() {
|
||||
setViewClass(HaloView.class);
|
||||
}
|
||||
|
||||
public static class HaloView extends ThymeleafReactiveView {
|
||||
|
||||
@Autowired
|
||||
private TemplateEngineManager engineManager;
|
||||
|
||||
@Autowired
|
||||
private ThemeResolver themeResolver;
|
||||
|
||||
@Override
|
||||
public Mono<Void> render(Map<String, ?> model, MediaType contentType,
|
||||
ServerWebExchange exchange) {
|
||||
// calculate the engine before rendering
|
||||
var theme = themeResolver.getTheme(exchange.getRequest());
|
||||
var templateEngine = engineManager.getTemplateEngine(theme);
|
||||
setTemplateEngine(templateEngine);
|
||||
|
||||
return super.render(model, contentType, exchange);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ISpringWebFluxTemplateEngine getTemplateEngine() {
|
||||
return super.getTemplateEngine();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,102 @@
|
|||
package run.halo.app.theme;
|
||||
|
||||
import java.io.FileNotFoundException;
|
||||
import java.nio.file.Path;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.boot.autoconfigure.thymeleaf.ThymeleafProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.ConcurrentLruCache;
|
||||
import org.springframework.util.ResourceUtils;
|
||||
import org.thymeleaf.dialect.IDialect;
|
||||
import org.thymeleaf.spring6.ISpringWebFluxTemplateEngine;
|
||||
import org.thymeleaf.templateresolver.FileTemplateResolver;
|
||||
import org.thymeleaf.templateresolver.ITemplateResolver;
|
||||
import run.halo.app.infra.NotFoundException;
|
||||
import run.halo.app.theme.engine.SpringWebFluxTemplateEngine;
|
||||
import run.halo.app.theme.message.ThemeMessageResolver;
|
||||
|
||||
/**
|
||||
* <p>The {@link TemplateEngineManager} uses an {@link ConcurrentLruCache LRU cache} to manage
|
||||
* theme's {@link ISpringWebFluxTemplateEngine}.</p>
|
||||
* <p>The default limit size of the {@link ConcurrentLruCache LRU cache} is
|
||||
* {@link TemplateEngineManager#CACHE_SIZE_LIMIT} to prevent unnecessary memory occupation.</p>
|
||||
* <p>If theme's {@link ISpringWebFluxTemplateEngine} already exists, it returns.</p>
|
||||
* <p>Otherwise, it checks whether the theme exists and creates the
|
||||
* {@link ISpringWebFluxTemplateEngine} into the LRU cache according to the {@link ThemeContext}
|
||||
* .</p>
|
||||
* <p>It is thread safe.</p>
|
||||
*
|
||||
* @author johnniang
|
||||
* @author guqing
|
||||
* @since 2.0.0
|
||||
*/
|
||||
@Component
|
||||
public class TemplateEngineManager {
|
||||
private static final int CACHE_SIZE_LIMIT = 5;
|
||||
private final ConcurrentLruCache<ThemeContext, ISpringWebFluxTemplateEngine> engineCache;
|
||||
|
||||
private final ThymeleafProperties thymeleafProperties;
|
||||
|
||||
private final ObjectProvider<ITemplateResolver> templateResolvers;
|
||||
|
||||
private final ObjectProvider<IDialect> dialects;
|
||||
|
||||
public TemplateEngineManager(ThymeleafProperties thymeleafProperties,
|
||||
ObjectProvider<ITemplateResolver> templateResolvers,
|
||||
ObjectProvider<IDialect> dialects) {
|
||||
this.thymeleafProperties = thymeleafProperties;
|
||||
this.templateResolvers = templateResolvers;
|
||||
this.dialects = dialects;
|
||||
engineCache = new ConcurrentLruCache<>(CACHE_SIZE_LIMIT, this::templateEngineGenerator);
|
||||
}
|
||||
|
||||
public ISpringWebFluxTemplateEngine getTemplateEngine(ThemeContext theme) {
|
||||
// cache not exists, will create new engine
|
||||
if (!engineCache.contains(theme)) {
|
||||
// before this, check if theme exists
|
||||
if (!fileExists(theme.getPath())) {
|
||||
throw new NotFoundException("Theme not found.");
|
||||
}
|
||||
}
|
||||
return engineCache.get(theme);
|
||||
}
|
||||
|
||||
private boolean fileExists(Path path) {
|
||||
try {
|
||||
return ResourceUtils.getFile(path.toUri()).exists();
|
||||
} catch (FileNotFoundException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private ISpringWebFluxTemplateEngine templateEngineGenerator(ThemeContext theme) {
|
||||
var engine = new SpringWebFluxTemplateEngine();
|
||||
engine.setEnableSpringELCompiler(thymeleafProperties.isEnableSpringElCompiler());
|
||||
engine.setMessageResolver(new ThemeMessageResolver(theme));
|
||||
engine.setLinkBuilder(new ThemeLinkBuilder(theme));
|
||||
engine.setRenderHiddenMarkersBeforeCheckboxes(
|
||||
thymeleafProperties.isRenderHiddenMarkersBeforeCheckboxes());
|
||||
|
||||
var mainResolver = haloTemplateResolver();
|
||||
mainResolver.setPrefix(theme.getPath() + "/templates/");
|
||||
engine.addTemplateResolver(mainResolver);
|
||||
|
||||
templateResolvers.orderedStream().forEach(engine::addTemplateResolver);
|
||||
dialects.orderedStream().forEach(engine::addDialect);
|
||||
|
||||
return engine;
|
||||
}
|
||||
|
||||
FileTemplateResolver haloTemplateResolver() {
|
||||
final var resolver = new FileTemplateResolver();
|
||||
resolver.setTemplateMode(thymeleafProperties.getMode());
|
||||
resolver.setPrefix(thymeleafProperties.getPrefix());
|
||||
resolver.setSuffix(thymeleafProperties.getSuffix());
|
||||
resolver.setCacheable(thymeleafProperties.isCache());
|
||||
resolver.setCheckExistence(thymeleafProperties.isCheckTemplate());
|
||||
if (thymeleafProperties.getEncoding() != null) {
|
||||
resolver.setCharacterEncoding(thymeleafProperties.getEncoding().name());
|
||||
}
|
||||
return resolver;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,62 @@
|
|||
package run.halo.app.theme;
|
||||
|
||||
import static org.springframework.web.reactive.function.server.RequestPredicates.GET;
|
||||
import static org.springframework.web.reactive.function.server.RequestPredicates.accept;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.io.FileSystemResource;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.reactive.function.server.RouterFunction;
|
||||
import org.springframework.web.reactive.function.server.RouterFunctions;
|
||||
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||
import run.halo.app.infra.properties.HaloProperties;
|
||||
import run.halo.app.infra.utils.FilePathUtils;
|
||||
|
||||
/**
|
||||
* @author guqing
|
||||
* @since 2.0.0
|
||||
*/
|
||||
@Configuration
|
||||
public class ThemeConfiguration {
|
||||
private final HaloProperties haloProperties;
|
||||
|
||||
public ThemeConfiguration(HaloProperties haloProperties) {
|
||||
this.haloProperties = haloProperties;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public RouterFunction<ServerResponse> themeAssets() {
|
||||
return RouterFunctions
|
||||
.route(GET("/themes/{themeName}/assets/{*resource}")
|
||||
.and(accept(MediaType.TEXT_HTML)),
|
||||
request -> {
|
||||
String themeName = request.pathVariable("themeName");
|
||||
String resource = request.pathVariable("resource");
|
||||
FileSystemResource fileSystemResource =
|
||||
new FileSystemResource(getThemeAssetsPath(themeName, resource));
|
||||
return ServerResponse.ok()
|
||||
.bodyValue(fileSystemResource);
|
||||
});
|
||||
}
|
||||
|
||||
@Bean
|
||||
RouterFunction<ServerResponse> routeIndex() {
|
||||
return RouterFunctions
|
||||
.route(GET("/").or(GET("/index"))
|
||||
.and(accept(MediaType.TEXT_HTML)),
|
||||
request -> ServerResponse.ok().render("index"));
|
||||
}
|
||||
|
||||
@Bean
|
||||
RouterFunction<ServerResponse> about() {
|
||||
return RouterFunctions.route(GET("/about").and(accept(MediaType.TEXT_HTML)),
|
||||
request -> ServerResponse.ok().render("about"));
|
||||
}
|
||||
|
||||
private Path getThemeAssetsPath(String themeName, String resource) {
|
||||
return FilePathUtils.combinePath(haloProperties.getWorkDir().toString(),
|
||||
"themes", themeName, "templates", "assets", resource);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
package run.halo.app.theme;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
/**
|
||||
* @author guqing
|
||||
* @since 2.0.0
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@EqualsAndHashCode(of = "name")
|
||||
public class ThemeContext {
|
||||
|
||||
public static final String THEME_PREVIEW_PARAM_NAME = "preview-theme";
|
||||
|
||||
private String name;
|
||||
|
||||
private Path path;
|
||||
|
||||
private boolean active;
|
||||
}
|
|
@ -0,0 +1,81 @@
|
|||
package run.halo.app.theme;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.web.util.UriComponentsBuilder;
|
||||
import org.thymeleaf.context.IExpressionContext;
|
||||
import org.thymeleaf.linkbuilder.StandardLinkBuilder;
|
||||
import run.halo.app.infra.utils.PathUtils;
|
||||
|
||||
/**
|
||||
* @author guqing
|
||||
* @since 2.0.0
|
||||
*/
|
||||
public class ThemeLinkBuilder extends StandardLinkBuilder {
|
||||
private static final String THEME_ASSETS_PREFIX = "/assets";
|
||||
private static final String THEME_PREVIEW_PREFIX = "/themes";
|
||||
|
||||
private final ThemeContext theme;
|
||||
|
||||
public ThemeLinkBuilder(ThemeContext theme) {
|
||||
this.theme = theme;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String processLink(IExpressionContext context, String link) {
|
||||
if (link == null || isLinkBaseAbsolute(link)) {
|
||||
return link;
|
||||
}
|
||||
|
||||
if (StringUtils.isBlank(link)) {
|
||||
link = "/";
|
||||
}
|
||||
|
||||
if (isAssetsRequest(link)) {
|
||||
return PathUtils.combinePath(THEME_PREVIEW_PREFIX, theme.getName(), link);
|
||||
}
|
||||
|
||||
// not assets link
|
||||
if (theme.isActive()) {
|
||||
return link;
|
||||
}
|
||||
|
||||
return UriComponentsBuilder.fromUriString(link)
|
||||
.queryParam(ThemeContext.THEME_PREVIEW_PARAM_NAME, theme.getName())
|
||||
.build().toString();
|
||||
}
|
||||
|
||||
private static boolean isLinkBaseAbsolute(final CharSequence linkBase) {
|
||||
final int linkBaseLen = linkBase.length();
|
||||
if (linkBaseLen < 2) {
|
||||
return false;
|
||||
}
|
||||
final char c0 = linkBase.charAt(0);
|
||||
if (c0 == 'm' || c0 == 'M') {
|
||||
// Let's check for "mailto:"
|
||||
if (linkBase.length() >= 7
|
||||
&& Character.toLowerCase(linkBase.charAt(1)) == 'a'
|
||||
&& Character.toLowerCase(linkBase.charAt(2)) == 'i'
|
||||
&& Character.toLowerCase(linkBase.charAt(3)) == 'l'
|
||||
&& Character.toLowerCase(linkBase.charAt(4)) == 't'
|
||||
&& Character.toLowerCase(linkBase.charAt(5)) == 'o'
|
||||
&& Character.toLowerCase(linkBase.charAt(6)) == ':') {
|
||||
return true;
|
||||
}
|
||||
} else if (c0 == '/') {
|
||||
return linkBase.charAt(1)
|
||||
== '/'; // It starts with '//' -> true, any other '/x' -> false
|
||||
}
|
||||
for (int i = 0; i < (linkBaseLen - 2); i++) {
|
||||
// Let's try to find the '://' sequence anywhere in the base --> true
|
||||
if (linkBase.charAt(i) == ':' && linkBase.charAt(i + 1) == '/'
|
||||
&& linkBase.charAt(i + 2) == '/') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean isAssetsRequest(String link) {
|
||||
return link.startsWith(THEME_ASSETS_PREFIX);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,92 @@
|
|||
package run.halo.app.theme;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.TimeZone;
|
||||
import java.util.function.Function;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.context.i18n.LocaleContext;
|
||||
import org.springframework.context.i18n.SimpleTimeZoneAwareLocaleContext;
|
||||
import org.springframework.http.HttpCookie;
|
||||
import org.springframework.lang.NonNull;
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.server.ServerWebExchange;
|
||||
import org.springframework.web.server.adapter.WebHttpHandlerBuilder;
|
||||
import org.springframework.web.server.i18n.AcceptHeaderLocaleContextResolver;
|
||||
|
||||
/**
|
||||
* @author guqing
|
||||
* @since 2.0.0
|
||||
*/
|
||||
@Slf4j
|
||||
@Component(WebHttpHandlerBuilder.LOCALE_CONTEXT_RESOLVER_BEAN_NAME)
|
||||
public class ThemeLocaleContextResolver extends AcceptHeaderLocaleContextResolver {
|
||||
public static final String TIME_ZONE_REQUEST_ATTRIBUTE_NAME =
|
||||
ThemeLocaleContextResolver.class.getName() + ".TIME_ZONE";
|
||||
public static final String LOCALE_REQUEST_ATTRIBUTE_NAME =
|
||||
ThemeLocaleContextResolver.class.getName() + ".LOCALE";
|
||||
|
||||
public static final String DEFAULT_PARAMETER_NAME = "language";
|
||||
public static final String TIME_ZONE_COOKIE_NAME = "time_zone";
|
||||
|
||||
private final Function<ServerWebExchange, TimeZone> defaultTimeZoneFunction =
|
||||
exchange -> getDefaultTimeZone();
|
||||
|
||||
@Override
|
||||
@NonNull
|
||||
public LocaleContext resolveLocaleContext(@NonNull ServerWebExchange exchange) {
|
||||
parseLocaleCookieIfNecessary(exchange);
|
||||
|
||||
Locale locale = getLocale(exchange);
|
||||
|
||||
return new SimpleTimeZoneAwareLocaleContext(locale,
|
||||
exchange.getAttribute(TIME_ZONE_REQUEST_ATTRIBUTE_NAME));
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private Locale getLocale(ServerWebExchange exchange) {
|
||||
String language = exchange.getRequest().getQueryParams()
|
||||
.getFirst(DEFAULT_PARAMETER_NAME);
|
||||
|
||||
Locale locale;
|
||||
if (StringUtils.isNotBlank(language)) {
|
||||
locale = new Locale(language);
|
||||
} else if (exchange.getAttribute(LOCALE_REQUEST_ATTRIBUTE_NAME) != null) {
|
||||
locale = exchange.getAttribute(LOCALE_REQUEST_ATTRIBUTE_NAME);
|
||||
} else {
|
||||
locale = super.resolveLocaleContext(exchange).getLocale();
|
||||
}
|
||||
return locale;
|
||||
}
|
||||
|
||||
private TimeZone getDefaultTimeZone() {
|
||||
return TimeZone.getDefault();
|
||||
}
|
||||
|
||||
private void parseLocaleCookieIfNecessary(ServerWebExchange exchange) {
|
||||
if (exchange.getAttribute(TIME_ZONE_REQUEST_ATTRIBUTE_NAME) == null) {
|
||||
TimeZone timeZone = null;
|
||||
HttpCookie cookie = exchange.getRequest()
|
||||
.getCookies()
|
||||
.getFirst(TIME_ZONE_COOKIE_NAME);
|
||||
if (cookie != null) {
|
||||
String value = cookie.getValue();
|
||||
timeZone = TimeZone.getTimeZone(value);
|
||||
}
|
||||
exchange.getAttributes().put(TIME_ZONE_REQUEST_ATTRIBUTE_NAME,
|
||||
(timeZone != null ? timeZone : this.defaultTimeZoneFunction.apply(exchange)));
|
||||
}
|
||||
|
||||
if (exchange.getAttribute(LOCALE_REQUEST_ATTRIBUTE_NAME) == null) {
|
||||
HttpCookie cookie = exchange.getRequest()
|
||||
.getCookies()
|
||||
.getFirst(DEFAULT_PARAMETER_NAME);
|
||||
if (cookie != null) {
|
||||
String value = cookie.getValue();
|
||||
exchange.getAttributes()
|
||||
.put(LOCALE_REQUEST_ATTRIBUTE_NAME, new Locale(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,67 @@
|
|||
package run.halo.app.theme;
|
||||
|
||||
import java.util.function.Function;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.http.server.reactive.ServerHttpRequest;
|
||||
import org.springframework.stereotype.Component;
|
||||
import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
|
||||
import run.halo.app.infra.SystemSetting.Theme;
|
||||
import run.halo.app.infra.properties.HaloProperties;
|
||||
import run.halo.app.infra.utils.FilePathUtils;
|
||||
|
||||
/**
|
||||
* @author johnniang
|
||||
* @since 2.0.0
|
||||
*/
|
||||
@Component
|
||||
public class ThemeResolver {
|
||||
private static final String THEME_WORK_DIR = "themes";
|
||||
private final SystemConfigurableEnvironmentFetcher environmentFetcher;
|
||||
private Function<ServerHttpRequest, ThemeContext> themeContextFunction;
|
||||
private final HaloProperties haloProperties;
|
||||
|
||||
public ThemeResolver(SystemConfigurableEnvironmentFetcher environmentFetcher,
|
||||
HaloProperties haloProperties) {
|
||||
this.environmentFetcher = environmentFetcher;
|
||||
this.haloProperties = haloProperties;
|
||||
themeContextFunction = this::defaultThemeContextFunction;
|
||||
}
|
||||
|
||||
public ThemeContext getTheme(ServerHttpRequest request) {
|
||||
return themeContextFunction.apply(request);
|
||||
}
|
||||
|
||||
private ThemeContext defaultThemeContextFunction(ServerHttpRequest request) {
|
||||
var builder = ThemeContext.builder();
|
||||
var themeName = request.getQueryParams().getFirst(ThemeContext.THEME_PREVIEW_PARAM_NAME);
|
||||
// TODO Fetch activated theme name from other place.
|
||||
String activation = environmentFetcher.fetch(Theme.GROUP, Theme.class)
|
||||
.map(Theme::getActive)
|
||||
.orElseThrow();
|
||||
if (StringUtils.isBlank(themeName)) {
|
||||
themeName = activation;
|
||||
}
|
||||
if (StringUtils.equals(activation, themeName)) {
|
||||
builder.active(true);
|
||||
}
|
||||
|
||||
// TODO Validate the existence of the theme name.
|
||||
|
||||
var path = FilePathUtils.combinePath(haloProperties.getWorkDir().toString(),
|
||||
THEME_WORK_DIR, themeName);
|
||||
|
||||
return builder
|
||||
.name(themeName)
|
||||
.path(path)
|
||||
.build();
|
||||
}
|
||||
|
||||
public Function<ServerHttpRequest, ThemeContext> getThemeContextFunction() {
|
||||
return themeContextFunction;
|
||||
}
|
||||
|
||||
public void setThemeContextFunction(
|
||||
Function<ServerHttpRequest, ThemeContext> themeContextFunction) {
|
||||
this.themeContextFunction = themeContextFunction;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,330 @@
|
|||
package run.halo.app.theme.engine;
|
||||
|
||||
import java.util.Set;
|
||||
import org.springframework.context.MessageSource;
|
||||
import org.springframework.context.MessageSourceAware;
|
||||
import org.thymeleaf.TemplateEngine;
|
||||
import org.thymeleaf.dialect.IDialect;
|
||||
import org.thymeleaf.messageresolver.IMessageResolver;
|
||||
import org.thymeleaf.messageresolver.StandardMessageResolver;
|
||||
import org.thymeleaf.spring6.ISpringTemplateEngine;
|
||||
import org.thymeleaf.spring6.dialect.SpringStandardDialect;
|
||||
import org.thymeleaf.spring6.messageresolver.SpringMessageResolver;
|
||||
|
||||
/**
|
||||
* <p>
|
||||
* Implementation of {@link ISpringTemplateEngine} meant for Spring-enabled applications,
|
||||
* that establishes by default an instance of {@link SpringStandardDialect}
|
||||
* as a dialect (instead of an instance of {@link org.thymeleaf.standard.StandardDialect}.
|
||||
* </p>
|
||||
* <p>
|
||||
* It also configures a {@link SpringMessageResolver} as message resolver, and
|
||||
* implements the {@link MessageSourceAware} interface in order to let Spring
|
||||
* automatically setting the {@link MessageSource} used at the application
|
||||
* (bean needs to have id {@code "messageSource"}). If this Spring standard setting
|
||||
* needs to be overridden, the {@link #setTemplateEngineMessageSource(MessageSource)} can
|
||||
* be used.
|
||||
* </p>
|
||||
* <p>
|
||||
* Code from
|
||||
* <a href="https://github.com/thymeleaf/thymeleaf/blob/3.1-master/lib/thymeleaf-spring6/src/main/java/org/thymeleaf/spring6/SpringTemplateEngine.java">Thymeleaf SpringTemplateEngine</a>
|
||||
* </p>
|
||||
*
|
||||
* @author Daniel Fernández
|
||||
* @author guqing
|
||||
* @see org.thymeleaf.spring6.SpringTemplateEngine
|
||||
* @since 2.0.0
|
||||
*/
|
||||
public class SpringTemplateEngine extends TemplateEngine
|
||||
implements ISpringTemplateEngine, MessageSourceAware {
|
||||
|
||||
private static final SpringStandardDialect SPRINGSTANDARD_DIALECT = new SpringStandardDialect();
|
||||
|
||||
private MessageSource messageSource = null;
|
||||
private MessageSource templateEngineMessageSource = null;
|
||||
|
||||
|
||||
public SpringTemplateEngine() {
|
||||
super();
|
||||
// This will set the SpringStandardDialect, overriding the Standard one set in the super
|
||||
// constructor
|
||||
super.setDialect(SPRINGSTANDARD_DIALECT);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* <p>
|
||||
* Implementation of the {@link MessageSourceAware#setMessageSource(MessageSource)}
|
||||
* method at the {@link MessageSourceAware} interface, provided so that
|
||||
* Spring is able to automatically set the currently configured {@link MessageSource} into
|
||||
* this template engine.
|
||||
* </p>
|
||||
* <p>
|
||||
* If several {@link MessageSource} implementation beans exist, Spring will inject here
|
||||
* the one with id {@code "messageSource"}.
|
||||
* </p>
|
||||
* <p>
|
||||
* This property <b>should not be set manually</b> in most scenarios (see
|
||||
* {@link #setTemplateEngineMessageSource(MessageSource)} instead).
|
||||
* </p>
|
||||
*
|
||||
* @param messageSource the message source to be used by the message resolver
|
||||
*/
|
||||
@Override
|
||||
public void setMessageSource(final MessageSource messageSource) {
|
||||
this.messageSource = messageSource;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* <p>
|
||||
* Convenience method for setting the message source that will
|
||||
* be used by this template engine, overriding the one automatically set by
|
||||
* Spring at the {@link #setMessageSource(MessageSource)} method.
|
||||
* </p>
|
||||
*
|
||||
* @param templateEngineMessageSource the message source to be used by the message resolver
|
||||
*/
|
||||
@Override
|
||||
public void setTemplateEngineMessageSource(final MessageSource templateEngineMessageSource) {
|
||||
this.templateEngineMessageSource = templateEngineMessageSource;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* <p>
|
||||
* Returns whether the SpringEL compiler should be enabled in SpringEL expressions or not.
|
||||
* </p>
|
||||
* <p>
|
||||
* (This is just a convenience method, equivalent to calling
|
||||
* {@link SpringStandardDialect#getEnableSpringELCompiler()} on the dialect instance itself.
|
||||
* It is provided
|
||||
* here in order to allow users to enable the SpEL compiler without
|
||||
* having to directly create instances of the {@link SpringStandardDialect})
|
||||
* </p>
|
||||
* <p>
|
||||
* Expression compilation can significantly improve the performance of Spring EL expressions,
|
||||
* but
|
||||
* might not be adequate for every environment. Read
|
||||
* <a href="http://docs.spring.io/spring/docs/current/spring-framework-reference/html/expressions.html#expressions-spel-compilation">the
|
||||
* official Spring documentation</a> for more detail.
|
||||
* </p>
|
||||
* <p>
|
||||
* Also note that although Spring includes a SpEL compiler since Spring 4.1, most expressions
|
||||
* in Thymeleaf templates will only be able to properly benefit from this compilation step
|
||||
* when at least
|
||||
* Spring Framework version 4.2.4 is used.
|
||||
* </p>
|
||||
* <p>
|
||||
* This flag is set to {@code false} by default.
|
||||
* </p>
|
||||
*
|
||||
* @return {@code true} if SpEL expressions should be compiled if possible, {@code false} if
|
||||
* not.
|
||||
*/
|
||||
public boolean getEnableSpringELCompiler() {
|
||||
final Set<IDialect> dialects = getDialects();
|
||||
for (final IDialect dialect : dialects) {
|
||||
if (dialect instanceof SpringStandardDialect) {
|
||||
return ((SpringStandardDialect) dialect).getEnableSpringELCompiler();
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* <p>
|
||||
* Sets whether the SpringEL compiler should be enabled in SpringEL expressions or not.
|
||||
* </p>
|
||||
* <p>
|
||||
* (This is just a convenience method, equivalent to calling
|
||||
* {@link SpringStandardDialect#setEnableSpringELCompiler(boolean)} on the dialect instance
|
||||
* itself. It is provided
|
||||
* here in order to allow users to enable the SpEL compiler without
|
||||
* having to directly create instances of the {@link SpringStandardDialect})
|
||||
* </p>
|
||||
* <p>
|
||||
* Expression compilation can significantly improve the performance of Spring EL expressions,
|
||||
* but
|
||||
* might not be adequate for every environment. Read
|
||||
* <a href="http://docs.spring.io/spring/docs/current/spring-framework-reference/html/expressions.html#expressions-spel-compilation">the
|
||||
* official Spring documentation</a> for more detail.
|
||||
* </p>
|
||||
* <p>
|
||||
* Also note that although Spring includes a SpEL compiler since Spring 4.1, most expressions
|
||||
* in Thymeleaf templates will only be able to properly benefit from this compilation step
|
||||
* when at least
|
||||
* Spring Framework version 4.2.4 is used.
|
||||
* </p>
|
||||
* <p>
|
||||
* This flag is set to {@code false} by default.
|
||||
* </p>
|
||||
*
|
||||
* @param enableSpringELCompiler {@code true} if SpEL expressions should be compiled if
|
||||
* possible, {@code false} if not.
|
||||
*/
|
||||
public void setEnableSpringELCompiler(final boolean enableSpringELCompiler) {
|
||||
final Set<IDialect> dialects = getDialects();
|
||||
for (final IDialect dialect : dialects) {
|
||||
if (dialect instanceof SpringStandardDialect) {
|
||||
((SpringStandardDialect) dialect).setEnableSpringELCompiler(enableSpringELCompiler);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* <p>
|
||||
* Returns whether the {@code <input type="hidden" ...>} marker tags rendered to signal the
|
||||
* presence
|
||||
* of checkboxes in forms when unchecked should be rendered <em>before</em> the checkbox tag
|
||||
* itself,
|
||||
* or after (default).
|
||||
* </p>
|
||||
* <p>
|
||||
* (This is just a convenience method, equivalent to calling
|
||||
* {@link SpringStandardDialect#getRenderHiddenMarkersBeforeCheckboxes()} on the dialect
|
||||
* instance
|
||||
* itself. It is provided here in order to allow users to modify this behaviour without
|
||||
* having to directly create instances of the {@link SpringStandardDialect})
|
||||
* </p>
|
||||
* <p>
|
||||
* A number of CSS frameworks and style guides assume that the {@code <label ...>} for a
|
||||
* checkbox
|
||||
* will appear in markup just after the {@code <input type="checkbox" ...>} tag itself, and
|
||||
* so the
|
||||
* default behaviour of rendering an {@code <input type="hidden" ...>} after the checkbox can
|
||||
* lead to
|
||||
* bad application of styles. By tuning this flag, developers can modify this behaviour and
|
||||
* make the hidden
|
||||
* tag appear before the checkbox (and thus allow the lable to truly appear right after the
|
||||
* checkbox).
|
||||
* </p>
|
||||
* <p>
|
||||
* Note this hidden field is introduced in order to signal the existence of the field in the
|
||||
* form being sent,
|
||||
* even if the checkbox is unchecked (no URL parameter is added for unchecked check boxes).
|
||||
* </p>
|
||||
* <p>
|
||||
* This flag is set to {@code false} by default.
|
||||
* </p>
|
||||
*
|
||||
* @return {@code true} if hidden markers should be rendered before the checkboxes, {@code
|
||||
* false} if not.
|
||||
* @since 3.0.10
|
||||
*/
|
||||
public boolean getRenderHiddenMarkersBeforeCheckboxes() {
|
||||
final Set<IDialect> dialects = getDialects();
|
||||
for (final IDialect dialect : dialects) {
|
||||
if (dialect instanceof SpringStandardDialect) {
|
||||
return ((SpringStandardDialect) dialect).getRenderHiddenMarkersBeforeCheckboxes();
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* <p>
|
||||
* Sets whether the {@code <input type="hidden" ...>} marker tags rendered to signal the
|
||||
* presence
|
||||
* of checkboxes in forms when unchecked should be rendered <em>before</em> the checkbox tag
|
||||
* itself,
|
||||
* or after (default).
|
||||
* </p>
|
||||
* <p>
|
||||
* (This is just a convenience method, equivalent to calling
|
||||
* {@link SpringStandardDialect#setRenderHiddenMarkersBeforeCheckboxes(boolean)} on the
|
||||
* dialect instance
|
||||
* itself. It is provided here in order to allow users to modify this behaviour without
|
||||
* having to directly create instances of the {@link SpringStandardDialect})
|
||||
* </p>
|
||||
* <p>
|
||||
* A number of CSS frameworks and style guides assume that the {@code <label ...>} for a
|
||||
* checkbox
|
||||
* will appear in markup just after the {@code <input type="checkbox" ...>} tag itself, and
|
||||
* so the
|
||||
* default behaviour of rendering an {@code <input type="hidden" ...>} after the checkbox can
|
||||
* lead to
|
||||
* bad application of styles. By tuning this flag, developers can modify this behaviour and
|
||||
* make the hidden
|
||||
* tag appear before the checkbox (and thus allow the lable to truly appear right after the
|
||||
* checkbox).
|
||||
* </p>
|
||||
* <p>
|
||||
* Note this hidden field is introduced in order to signal the existence of the field in the
|
||||
* form being sent,
|
||||
* even if the checkbox is unchecked (no URL parameter is added for unchecked check boxes).
|
||||
* </p>
|
||||
* <p>
|
||||
* This flag is set to {@code false} by default.
|
||||
* </p>
|
||||
*
|
||||
* @param renderHiddenMarkersBeforeCheckboxes {@code true} if hidden markers should be rendered
|
||||
* before the checkboxes, {@code false} if not.
|
||||
* @since 3.0.10
|
||||
*/
|
||||
public void setRenderHiddenMarkersBeforeCheckboxes(
|
||||
final boolean renderHiddenMarkersBeforeCheckboxes) {
|
||||
final Set<IDialect> dialects = getDialects();
|
||||
for (final IDialect dialect : dialects) {
|
||||
if (dialect instanceof SpringStandardDialect) {
|
||||
((SpringStandardDialect) dialect).setRenderHiddenMarkersBeforeCheckboxes(
|
||||
renderHiddenMarkersBeforeCheckboxes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
protected void initializeSpecific() {
|
||||
|
||||
// First of all, give the opportunity to subclasses to apply their own configurations
|
||||
initializeSpringSpecific();
|
||||
|
||||
// Once the subclasses have had their opportunity, compute configurations belonging to
|
||||
// SpringTemplateEngine
|
||||
super.initializeSpecific();
|
||||
|
||||
final MessageSource messageSource =
|
||||
this.templateEngineMessageSource == null ? this.messageSource
|
||||
: this.templateEngineMessageSource;
|
||||
|
||||
final IMessageResolver messageResolver;
|
||||
if (messageSource != null) {
|
||||
final SpringMessageResolver springMessageResolver = new SpringMessageResolver();
|
||||
springMessageResolver.setMessageSource(messageSource);
|
||||
messageResolver = springMessageResolver;
|
||||
} else {
|
||||
messageResolver = new StandardMessageResolver();
|
||||
}
|
||||
|
||||
super.addMessageResolver(messageResolver);
|
||||
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* <p>
|
||||
* This method performs additional initializations required for a
|
||||
* {@code SpringTemplateEngine} subclass instance. This method
|
||||
* is called before the first execution of
|
||||
* {@link TemplateEngine#process(String, org.thymeleaf.context.IContext)}
|
||||
* or {@link TemplateEngine#processThrottled(String, org.thymeleaf.context.IContext)}
|
||||
* in order to create all the structures required for a quick execution of
|
||||
* templates.
|
||||
* </p>
|
||||
* <p>
|
||||
* THIS METHOD IS INTERNAL AND SHOULD <b>NEVER</b> BE CALLED DIRECTLY.
|
||||
* </p>
|
||||
* <p>
|
||||
* The implementation of this method does nothing, and it is designed
|
||||
* for being overridden by subclasses of {@code SpringTemplateEngine}.
|
||||
* </p>
|
||||
*/
|
||||
protected void initializeSpringSpecific() {
|
||||
// Nothing to be executed here. Meant for extension
|
||||
}
|
||||
|
||||
}
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,261 @@
|
|||
package run.halo.app.theme.message;
|
||||
|
||||
import java.io.BufferedInputStream;
|
||||
import java.io.BufferedReader;
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.Reader;
|
||||
import java.text.MessageFormat;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Properties;
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.thymeleaf.exceptions.TemplateInputException;
|
||||
import org.thymeleaf.exceptions.TemplateProcessingException;
|
||||
import org.thymeleaf.util.StringUtils;
|
||||
import run.halo.app.theme.ThemeContext;
|
||||
|
||||
/**
|
||||
* @author guqing
|
||||
* @since 2.0.0
|
||||
*/
|
||||
public class ThemeMessageResolutionUtils {
|
||||
|
||||
private static final Map<String, String> EMPTY_MESSAGES = Collections.emptyMap();
|
||||
private static final String PROPERTIES_FILE_EXTENSION = ".properties";
|
||||
private static final String LOCATION = "i18n";
|
||||
private static final Object[] EMPTY_MESSAGE_PARAMETERS = new Object[0];
|
||||
|
||||
@Nullable
|
||||
private static Reader messageReader(String messageResourceName, ThemeContext theme)
|
||||
throws FileNotFoundException {
|
||||
var themePath = theme.getPath();
|
||||
File messageFile = themePath.resolve(messageResourceName).toFile();
|
||||
if (!messageFile.exists()) {
|
||||
return null;
|
||||
}
|
||||
final InputStream inputStream = new FileInputStream(messageFile);
|
||||
return new BufferedReader(new InputStreamReader(new BufferedInputStream(inputStream)));
|
||||
}
|
||||
|
||||
public static Map<String, String> resolveMessagesForTemplate(final Locale locale,
|
||||
ThemeContext theme) {
|
||||
|
||||
// Compute all the resource names we should use: *_gl_ES-gheada.properties, *_gl_ES
|
||||
// .properties, _gl.properties...
|
||||
// The order here is important: as we will let values from more specific files
|
||||
// overwrite those in less specific,
|
||||
// (e.g. a value for gl_ES will have more precedence than a value for gl). So we will
|
||||
// iterate these resource
|
||||
// names from less specific to more specific.
|
||||
final List<String>
|
||||
messageResourceNames = computeMessageResourceNamesFromBase(locale);
|
||||
|
||||
// Build the combined messages
|
||||
Map<String, String> combinedMessages = null;
|
||||
for (final String messageResourceName : messageResourceNames) {
|
||||
try {
|
||||
final Reader messageResourceReader = messageReader(messageResourceName, theme);
|
||||
if (messageResourceReader != null) {
|
||||
|
||||
final Properties messageProperties =
|
||||
readMessagesResource(messageResourceReader);
|
||||
if (messageProperties != null && !messageProperties.isEmpty()) {
|
||||
|
||||
if (combinedMessages == null) {
|
||||
combinedMessages = new HashMap<>(20);
|
||||
}
|
||||
|
||||
for (final Map.Entry<Object, Object> propertyEntry :
|
||||
messageProperties.entrySet()) {
|
||||
combinedMessages.put((String) propertyEntry.getKey(),
|
||||
(String) propertyEntry.getValue());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
} catch (final IOException ignored) {
|
||||
// File might not exist, simply try the next one
|
||||
}
|
||||
}
|
||||
|
||||
if (combinedMessages == null) {
|
||||
return EMPTY_MESSAGES;
|
||||
}
|
||||
|
||||
return Collections.unmodifiableMap(combinedMessages);
|
||||
}
|
||||
|
||||
public static Map<String, String> resolveMessagesForOrigin(final Class<?> origin,
|
||||
final Locale locale) {
|
||||
|
||||
final Map<String, String> combinedMessages = new HashMap<>(20);
|
||||
|
||||
Class<?> currentClass = origin;
|
||||
combinedMessages.putAll(resolveMessagesForSpecificClass(currentClass, locale));
|
||||
|
||||
while (!currentClass.getSuperclass().equals(Object.class)) {
|
||||
|
||||
currentClass = currentClass.getSuperclass();
|
||||
final Map<String, String> messagesForCurrentClass =
|
||||
resolveMessagesForSpecificClass(currentClass, locale);
|
||||
for (final String messageKey : messagesForCurrentClass.keySet()) {
|
||||
if (!combinedMessages.containsKey(messageKey)) {
|
||||
combinedMessages.put(messageKey, messagesForCurrentClass.get(messageKey));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Collections.unmodifiableMap(combinedMessages);
|
||||
|
||||
}
|
||||
|
||||
|
||||
private static Map<String, String> resolveMessagesForSpecificClass(
|
||||
final Class<?> originClass, final Locale locale) {
|
||||
|
||||
|
||||
final ClassLoader originClassLoader = originClass.getClassLoader();
|
||||
|
||||
// Compute all the resource names we should use: *_gl_ES-gheada.properties, *_gl_ES
|
||||
// .properties, _gl.properties...
|
||||
// The order here is important: as we will let values from more specific files
|
||||
// overwrite those in less specific,
|
||||
// (e.g. a value for gl_ES will have more precedence than a value for gl). So we will
|
||||
// iterate these resource
|
||||
// names from less specific to more specific.
|
||||
final List<String> messageResourceNames =
|
||||
computeMessageResourceNamesFromBase(locale);
|
||||
|
||||
// Build the combined messages
|
||||
Map<String, String> combinedMessages = null;
|
||||
for (final String messageResourceName : messageResourceNames) {
|
||||
|
||||
final InputStream inputStream =
|
||||
originClassLoader.getResourceAsStream(messageResourceName);
|
||||
if (inputStream != null) {
|
||||
|
||||
// At this point we cannot be specified a character encoding (that's only for
|
||||
// template resolution),
|
||||
// so we will use the standard character encoding for .properties files,
|
||||
// which is ISO-8859-1
|
||||
// (see Properties#load(InputStream) javadoc).
|
||||
final InputStreamReader messageResourceReader =
|
||||
new InputStreamReader(inputStream);
|
||||
|
||||
final Properties messageProperties =
|
||||
readMessagesResource(messageResourceReader);
|
||||
if (messageProperties != null && !messageProperties.isEmpty()) {
|
||||
|
||||
if (combinedMessages == null) {
|
||||
combinedMessages = new HashMap<>(20);
|
||||
}
|
||||
|
||||
for (final Map.Entry<Object, Object> propertyEntry :
|
||||
messageProperties.entrySet()) {
|
||||
combinedMessages.put((String) propertyEntry.getKey(),
|
||||
(String) propertyEntry.getValue());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if (combinedMessages == null) {
|
||||
return EMPTY_MESSAGES;
|
||||
}
|
||||
|
||||
return Collections.unmodifiableMap(combinedMessages);
|
||||
}
|
||||
|
||||
|
||||
private static List<String> computeMessageResourceNamesFromBase(final Locale locale) {
|
||||
|
||||
final List<String> resourceNames = new ArrayList<>(5);
|
||||
|
||||
if (StringUtils.isEmptyOrWhitespace(locale.getLanguage())) {
|
||||
throw new TemplateProcessingException(
|
||||
"Locale \"" + locale + "\" "
|
||||
+ "cannot be used as it does not specify a language.");
|
||||
}
|
||||
|
||||
resourceNames.add(getResourceName("default"));
|
||||
resourceNames.add(getResourceName(locale.getLanguage()));
|
||||
|
||||
if (!StringUtils.isEmptyOrWhitespace(locale.getCountry())) {
|
||||
resourceNames.add(
|
||||
getResourceName(locale.getLanguage() + "_" + locale.getCountry()));
|
||||
}
|
||||
|
||||
if (!StringUtils.isEmptyOrWhitespace(locale.getVariant())) {
|
||||
resourceNames.add(getResourceName(
|
||||
locale.getLanguage() + "_" + locale.getCountry() + "-" + locale.getVariant()));
|
||||
}
|
||||
|
||||
return resourceNames;
|
||||
|
||||
}
|
||||
|
||||
private static String getResourceName(String name) {
|
||||
return LOCATION + "/" + name + PROPERTIES_FILE_EXTENSION;
|
||||
}
|
||||
|
||||
|
||||
private static Properties readMessagesResource(final Reader propertiesReader) {
|
||||
if (propertiesReader == null) {
|
||||
return null;
|
||||
}
|
||||
final Properties properties = new Properties();
|
||||
try (propertiesReader) {
|
||||
// Note Properties#load(Reader) this is JavaSE 6 specific, but Thymeleaf 3.0 does
|
||||
// not support Java 5 anymore...
|
||||
properties.load(propertiesReader);
|
||||
} catch (final Exception e) {
|
||||
throw new TemplateInputException("Exception loading messages file", e);
|
||||
}
|
||||
// ignore errors closing
|
||||
return properties;
|
||||
}
|
||||
|
||||
public static String formatMessage(final Locale locale, final String message,
|
||||
final Object[] messageParameters) {
|
||||
if (message == null) {
|
||||
return null;
|
||||
}
|
||||
if (!isFormatCandidate(message)) {
|
||||
// trying to avoid creating MessageFormat if not needed
|
||||
return message;
|
||||
}
|
||||
final MessageFormat messageFormat = new MessageFormat(message, locale);
|
||||
return messageFormat.format(
|
||||
(messageParameters != null ? messageParameters : EMPTY_MESSAGE_PARAMETERS));
|
||||
}
|
||||
|
||||
/*
|
||||
* This will allow us to determine whether a message might actually contain parameter
|
||||
* placeholders.
|
||||
*/
|
||||
private static boolean isFormatCandidate(final String message) {
|
||||
char c;
|
||||
int n = message.length();
|
||||
while (n-- != 0) {
|
||||
c = message.charAt(n);
|
||||
if (c == '}' || c == '\'') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,37 @@
|
|||
package run.halo.app.theme.message;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import org.thymeleaf.messageresolver.StandardMessageResolver;
|
||||
import org.thymeleaf.templateresource.ITemplateResource;
|
||||
import run.halo.app.theme.ThemeContext;
|
||||
|
||||
/**
|
||||
* @author guqing
|
||||
* @since 2.0.0
|
||||
*/
|
||||
public class ThemeMessageResolver extends StandardMessageResolver {
|
||||
|
||||
private final ThemeContext theme;
|
||||
|
||||
public ThemeMessageResolver(ThemeContext theme) {
|
||||
this.theme = theme;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Map<String, String> resolveMessagesForTemplate(String template,
|
||||
ITemplateResource templateResource,
|
||||
Locale locale) {
|
||||
return ThemeMessageResolutionUtils.resolveMessagesForTemplate(locale, theme);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Map<String, String> resolveMessagesForOrigin(Class<?> origin, Locale locale) {
|
||||
return ThemeMessageResolutionUtils.resolveMessagesForOrigin(origin, locale);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String formatMessage(Locale locale, String message, Object[] messageParameters) {
|
||||
return ThemeMessageResolutionUtils.formatMessage(locale, message, messageParameters);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
apiVersion: v1alpha1
|
||||
kind: "ConfigMap"
|
||||
metadata:
|
||||
name: system
|
||||
data:
|
||||
theme: |
|
||||
{
|
||||
"active": "default"
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
package run.halo.app.theme;
|
||||
|
||||
import java.nio.file.Paths;
|
||||
import org.json.JSONException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.skyscreamer.jsonassert.JSONAssert;
|
||||
import run.halo.app.infra.utils.JsonUtils;
|
||||
|
||||
/**
|
||||
* Tests for {@link ThemeContext}.
|
||||
*
|
||||
* @author guqing
|
||||
* @since 2.0.0
|
||||
*/
|
||||
class ThemeContextTest {
|
||||
|
||||
@Test
|
||||
void constructorBuilderTest() throws JSONException {
|
||||
ThemeContext testTheme = ThemeContext.builder()
|
||||
.name("testTheme")
|
||||
.path(Paths.get("/tmp/themes/testTheme"))
|
||||
.active(true)
|
||||
.build();
|
||||
String s = JsonUtils.objectToJson(testTheme);
|
||||
JSONAssert.assertEquals("""
|
||||
{
|
||||
"name": "testTheme",
|
||||
"path": "file:///tmp/themes/testTheme",
|
||||
"active": true
|
||||
}
|
||||
""",
|
||||
s,
|
||||
false);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,91 @@
|
|||
package run.halo.app.theme;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.nio.file.Paths;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Tests for {@link ThemeLinkBuilder}.
|
||||
*
|
||||
* @author guqing
|
||||
* @since 2.0.0
|
||||
*/
|
||||
class ThemeLinkBuilderTest {
|
||||
private ThemeLinkBuilder themeLinkBuilder;
|
||||
|
||||
@Test
|
||||
void processTemplateLinkWithNoActive() {
|
||||
themeLinkBuilder = new ThemeLinkBuilder(getTheme(false));
|
||||
|
||||
String link = "/post";
|
||||
String processed = themeLinkBuilder.processLink(null, link);
|
||||
assertThat(processed).isEqualTo("/post?preview-theme=test-theme");
|
||||
|
||||
processed = themeLinkBuilder.processLink(null, "/post?foo=bar");
|
||||
assertThat(processed).isEqualTo("/post?foo=bar&preview-theme=test-theme");
|
||||
}
|
||||
|
||||
@Test
|
||||
void processTemplateLinkWithActive() {
|
||||
themeLinkBuilder = new ThemeLinkBuilder(getTheme(true));
|
||||
|
||||
String link = "/post";
|
||||
String processed = themeLinkBuilder.processLink(null, link);
|
||||
assertThat(processed).isEqualTo("/post");
|
||||
}
|
||||
|
||||
@Test
|
||||
void processAssetsLink() {
|
||||
// activated theme
|
||||
themeLinkBuilder = new ThemeLinkBuilder(getTheme(true));
|
||||
|
||||
String link = "/assets/css/style.css";
|
||||
String processed = themeLinkBuilder.processLink(null, link);
|
||||
assertThat(processed).isEqualTo("/themes/test-theme/assets/css/style.css");
|
||||
|
||||
// preview theme
|
||||
getTheme(false);
|
||||
link = "/assets/js/main.js";
|
||||
processed = themeLinkBuilder.processLink(null, link);
|
||||
assertThat(processed).isEqualTo("/themes/test-theme/assets/js/main.js");
|
||||
}
|
||||
|
||||
@Test
|
||||
void processNullLink() {
|
||||
themeLinkBuilder = new ThemeLinkBuilder(getTheme(false));
|
||||
|
||||
String link = null;
|
||||
String processed = themeLinkBuilder.processLink(null, link);
|
||||
assertThat(processed).isEqualTo(null);
|
||||
|
||||
// empty link
|
||||
link = "";
|
||||
processed = themeLinkBuilder.processLink(null, link);
|
||||
assertThat(processed).isEqualTo("/?preview-theme=test-theme");
|
||||
}
|
||||
|
||||
@Test
|
||||
void processAbsoluteLink() {
|
||||
themeLinkBuilder = new ThemeLinkBuilder(getTheme(false));
|
||||
String link = "https://github.com/halo-dev";
|
||||
String processed = themeLinkBuilder.processLink(null, link);
|
||||
assertThat(processed).isEqualTo(link);
|
||||
|
||||
link = "http://example.com";
|
||||
processed = themeLinkBuilder.processLink(null, link);
|
||||
assertThat(processed).isEqualTo(link);
|
||||
|
||||
link = "//example.com";
|
||||
processed = themeLinkBuilder.processLink(null, link);
|
||||
assertThat(processed).isEqualTo(link);
|
||||
}
|
||||
|
||||
private ThemeContext getTheme(boolean isActive) {
|
||||
return ThemeContext.builder()
|
||||
.name("test-theme")
|
||||
.path(Paths.get("/themes/test-theme"))
|
||||
.active(isActive)
|
||||
.build();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,193 @@
|
|||
package run.halo.app.theme;
|
||||
|
||||
import static java.util.Locale.CANADA;
|
||||
import static java.util.Locale.CHINA;
|
||||
import static java.util.Locale.CHINESE;
|
||||
import static java.util.Locale.ENGLISH;
|
||||
import static java.util.Locale.GERMAN;
|
||||
import static java.util.Locale.GERMANY;
|
||||
import static java.util.Locale.JAPAN;
|
||||
import static java.util.Locale.JAPANESE;
|
||||
import static java.util.Locale.KOREA;
|
||||
import static java.util.Locale.UK;
|
||||
import static java.util.Locale.US;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static run.halo.app.theme.ThemeLocaleContextResolver.DEFAULT_PARAMETER_NAME;
|
||||
import static run.halo.app.theme.ThemeLocaleContextResolver.TIME_ZONE_COOKIE_NAME;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.Locale;
|
||||
import java.util.TimeZone;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.context.i18n.TimeZoneAwareLocaleContext;
|
||||
import org.springframework.http.HttpCookie;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
|
||||
import org.springframework.mock.web.server.MockServerWebExchange;
|
||||
import org.springframework.web.server.ServerWebExchange;
|
||||
|
||||
/**
|
||||
* Test for {@link ThemeLocaleContextResolver}.
|
||||
*
|
||||
* @author guqing
|
||||
* @since 2.0.0
|
||||
*/
|
||||
class ThemeLocaleContextResolverTest {
|
||||
private final ThemeLocaleContextResolver resolver = new ThemeLocaleContextResolver();
|
||||
|
||||
@Test
|
||||
public void resolveTimeZone() {
|
||||
TimeZoneAwareLocaleContext localeContext =
|
||||
(TimeZoneAwareLocaleContext) this.resolver.resolveLocaleContext(
|
||||
exchangeTimeZone(CHINA));
|
||||
assertThat(localeContext.getTimeZone()).isNotNull();
|
||||
assertThat(localeContext.getTimeZone())
|
||||
.isEqualTo(TimeZone.getTimeZone("America/Adak"));
|
||||
assertThat(localeContext.getLocale()).isNotNull();
|
||||
assertThat(localeContext.getLocale().getLanguage()).isEqualTo("en");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resolve() {
|
||||
assertThat(this.resolver.resolveLocaleContext(exchange(CANADA)).getLocale())
|
||||
.isEqualTo(CANADA);
|
||||
assertThat(this.resolver.resolveLocaleContext(exchange(US, CANADA)).getLocale())
|
||||
.isEqualTo(US);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resolveFromParam() {
|
||||
assertThat(this.resolver.resolveLocaleContext(exchangeForParam("en")).getLocale())
|
||||
.isEqualTo(ENGLISH);
|
||||
assertThat(this.resolver.resolveLocaleContext(exchangeForParam("zh")).getLocale())
|
||||
.isEqualTo(CHINESE);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resolvePreferredSupported() {
|
||||
this.resolver.setSupportedLocales(Collections.singletonList(CANADA));
|
||||
assertThat(this.resolver.resolveLocaleContext(exchange(US, CANADA)).getLocale()).isEqualTo(
|
||||
CANADA);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resolvePreferredNotSupported() {
|
||||
this.resolver.setSupportedLocales(Collections.singletonList(CANADA));
|
||||
assertThat(this.resolver.resolveLocaleContext(exchange(US, UK)).getLocale()).isEqualTo(US);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resolvePreferredNotSupportedWithDefault() {
|
||||
this.resolver.setSupportedLocales(Arrays.asList(US, JAPAN));
|
||||
this.resolver.setDefaultLocale(JAPAN);
|
||||
assertThat(this.resolver.resolveLocaleContext(exchange(KOREA)).getLocale()).isEqualTo(
|
||||
JAPAN);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resolvePreferredAgainstLanguageOnly() {
|
||||
this.resolver.setSupportedLocales(Collections.singletonList(ENGLISH));
|
||||
assertThat(
|
||||
this.resolver.resolveLocaleContext(exchange(GERMANY, US, UK)).getLocale()).isEqualTo(
|
||||
ENGLISH);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resolvePreferredAgainstCountryIfPossible() {
|
||||
this.resolver.setSupportedLocales(Arrays.asList(ENGLISH, UK));
|
||||
assertThat(
|
||||
this.resolver.resolveLocaleContext(exchange(GERMANY, US, UK)).getLocale()).isEqualTo(
|
||||
UK);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resolvePreferredAgainstLanguageWithMultipleSupportedLocales() {
|
||||
this.resolver.setSupportedLocales(Arrays.asList(GERMAN, US));
|
||||
assertThat(
|
||||
this.resolver.resolveLocaleContext(exchange(GERMANY, US, UK)).getLocale()).isEqualTo(
|
||||
GERMAN);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resolveMissingAcceptLanguageHeader() {
|
||||
MockServerHttpRequest request = MockServerHttpRequest.get("/").build();
|
||||
MockServerWebExchange exchange = MockServerWebExchange.from(request);
|
||||
assertThat(this.resolver.resolveLocaleContext(exchange).getLocale()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resolveMissingAcceptLanguageHeaderWithDefault() {
|
||||
this.resolver.setDefaultLocale(US);
|
||||
|
||||
MockServerHttpRequest request = MockServerHttpRequest.get("/").build();
|
||||
MockServerWebExchange exchange = MockServerWebExchange.from(request);
|
||||
assertThat(this.resolver.resolveLocaleContext(exchange).getLocale()).isEqualTo(US);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resolveEmptyAcceptLanguageHeader() {
|
||||
MockServerHttpRequest request =
|
||||
MockServerHttpRequest.get("/").header(HttpHeaders.ACCEPT_LANGUAGE, "").build();
|
||||
MockServerWebExchange exchange = MockServerWebExchange.from(request);
|
||||
assertThat(this.resolver.resolveLocaleContext(exchange).getLocale()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resolveEmptyAcceptLanguageHeaderWithDefault() {
|
||||
this.resolver.setDefaultLocale(US);
|
||||
|
||||
MockServerHttpRequest request =
|
||||
MockServerHttpRequest.get("/").header(HttpHeaders.ACCEPT_LANGUAGE, "").build();
|
||||
MockServerWebExchange exchange = MockServerWebExchange.from(request);
|
||||
assertThat(this.resolver.resolveLocaleContext(exchange).getLocale()).isEqualTo(US);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resolveInvalidAcceptLanguageHeader() {
|
||||
MockServerHttpRequest request =
|
||||
MockServerHttpRequest.get("/").header(HttpHeaders.ACCEPT_LANGUAGE, "en_US").build();
|
||||
MockServerWebExchange exchange = MockServerWebExchange.from(request);
|
||||
assertThat(this.resolver.resolveLocaleContext(exchange).getLocale()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resolveInvalidAcceptLanguageHeaderWithDefault() {
|
||||
this.resolver.setDefaultLocale(US);
|
||||
|
||||
MockServerHttpRequest request =
|
||||
MockServerHttpRequest.get("/").header(HttpHeaders.ACCEPT_LANGUAGE, "en_US").build();
|
||||
MockServerWebExchange exchange = MockServerWebExchange.from(request);
|
||||
assertThat(this.resolver.resolveLocaleContext(exchange).getLocale()).isEqualTo(US);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void defaultLocale() {
|
||||
this.resolver.setDefaultLocale(JAPANESE);
|
||||
MockServerHttpRequest request = MockServerHttpRequest.get("/").build();
|
||||
MockServerWebExchange exchange = MockServerWebExchange.from(request);
|
||||
assertThat(this.resolver.resolveLocaleContext(exchange).getLocale()).isEqualTo(JAPANESE);
|
||||
|
||||
request = MockServerHttpRequest.get("/").acceptLanguageAsLocales(US).build();
|
||||
exchange = MockServerWebExchange.from(request);
|
||||
assertThat(this.resolver.resolveLocaleContext(exchange).getLocale()).isEqualTo(US);
|
||||
}
|
||||
|
||||
|
||||
private ServerWebExchange exchange(Locale... locales) {
|
||||
return MockServerWebExchange.from(
|
||||
MockServerHttpRequest.get("").acceptLanguageAsLocales(locales));
|
||||
}
|
||||
|
||||
private ServerWebExchange exchangeTimeZone(Locale... locales) {
|
||||
return MockServerWebExchange.from(
|
||||
MockServerHttpRequest.get("").acceptLanguageAsLocales(locales)
|
||||
.cookie(new HttpCookie(TIME_ZONE_COOKIE_NAME, "America/Adak"))
|
||||
.cookie(new HttpCookie(DEFAULT_PARAMETER_NAME, "en")));
|
||||
}
|
||||
|
||||
private ServerWebExchange exchangeForParam(String language) {
|
||||
return MockServerWebExchange.from(
|
||||
MockServerHttpRequest.get("/index?language=" + language));
|
||||
}
|
||||
}
|
|
@ -0,0 +1,58 @@
|
|||
package run.halo.app.theme.message;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.io.FileNotFoundException;
|
||||
import java.net.URL;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.util.ResourceUtils;
|
||||
import run.halo.app.theme.ThemeContext;
|
||||
|
||||
/**
|
||||
* @author guqing
|
||||
* @since 2.0.0
|
||||
*/
|
||||
class ThemeMessageResolutionUtilsTest {
|
||||
private URL defaultThemeUrl;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws FileNotFoundException {
|
||||
defaultThemeUrl = ResourceUtils.getURL("classpath:themes/default");
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveMessagesForTemplateForDefault() {
|
||||
Map<String, String> properties =
|
||||
ThemeMessageResolutionUtils.resolveMessagesForTemplate(Locale.CHINESE, getTheme());
|
||||
assertThat(properties).hasSize(1);
|
||||
assertThat(properties).containsEntry("index.welcome", "欢迎来到首页");
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveMessagesForTemplateForEnglish() {
|
||||
Map<String, String> properties =
|
||||
ThemeMessageResolutionUtils.resolveMessagesForTemplate(Locale.ENGLISH, getTheme());
|
||||
assertThat(properties).hasSize(1);
|
||||
assertThat(properties).containsEntry("index.welcome", "Welcome to the index");
|
||||
}
|
||||
|
||||
@Test
|
||||
void messageFormat() {
|
||||
String s =
|
||||
ThemeMessageResolutionUtils.formatMessage(Locale.ENGLISH, "Welcome {0} to the index",
|
||||
new Object[] {"Halo"});
|
||||
assertThat(s).isEqualTo("Welcome Halo to the index");
|
||||
}
|
||||
|
||||
ThemeContext getTheme() {
|
||||
return ThemeContext.builder()
|
||||
.name("default")
|
||||
.path(Paths.get(defaultThemeUrl.getPath()))
|
||||
.active(true)
|
||||
.build();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,225 @@
|
|||
package run.halo.app.theme.message;
|
||||
|
||||
import java.io.FileNotFoundException;
|
||||
import java.net.URL;
|
||||
import java.nio.file.Paths;
|
||||
import java.time.Duration;
|
||||
import java.util.function.Function;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.context.TestConfiguration;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.server.reactive.ServerHttpRequest;
|
||||
import org.springframework.test.web.reactive.server.WebTestClient;
|
||||
import org.springframework.util.ResourceUtils;
|
||||
import org.springframework.web.reactive.function.server.RequestPredicates;
|
||||
import org.springframework.web.reactive.function.server.RouterFunction;
|
||||
import org.springframework.web.reactive.function.server.RouterFunctions;
|
||||
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||
import run.halo.app.theme.ThemeContext;
|
||||
import run.halo.app.theme.ThemeResolver;
|
||||
|
||||
/**
|
||||
* Tests for {@link ThemeMessageResolver}.
|
||||
*
|
||||
* @author guqing
|
||||
* @since 2.0.0
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||
public class ThemeMessageResolverIntegrationTest {
|
||||
@Autowired
|
||||
private ApplicationContext applicationContext;
|
||||
@Autowired
|
||||
private ThemeResolver themeResolver;
|
||||
private URL defaultThemeUrl;
|
||||
private URL otherThemeUrl;
|
||||
|
||||
Function<ServerHttpRequest, ThemeContext> themeContextFunction;
|
||||
|
||||
private WebTestClient webTestClient;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws FileNotFoundException {
|
||||
themeContextFunction = themeResolver.getThemeContextFunction();
|
||||
webTestClient = WebTestClient
|
||||
.bindToApplicationContext(applicationContext)
|
||||
.configureClient()
|
||||
.responseTimeout(Duration.ofMinutes(1))
|
||||
.build();
|
||||
|
||||
defaultThemeUrl = ResourceUtils.getURL("classpath:themes/default");
|
||||
otherThemeUrl = ResourceUtils.getURL("classpath:themes/other");
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
this.themeResolver.setThemeContextFunction(themeContextFunction);
|
||||
}
|
||||
|
||||
@Test
|
||||
void messageResolverWhenDefaultTheme() {
|
||||
themeResolver.setThemeContextFunction(request -> createDefaultContext());
|
||||
webTestClient.get()
|
||||
.uri("/?language=zh")
|
||||
.exchange()
|
||||
.expectStatus()
|
||||
.isOk()
|
||||
.expectBody(String.class)
|
||||
.isEqualTo("""
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Title</title>
|
||||
</head>
|
||||
<body>
|
||||
index
|
||||
<div>zh</div>
|
||||
<div>欢迎来到首页</div>
|
||||
</body>
|
||||
</html>
|
||||
""");
|
||||
}
|
||||
|
||||
@Test
|
||||
void messageResolverForEnLanguageWhenDefaultTheme() {
|
||||
themeResolver.setThemeContextFunction(request -> createDefaultContext());
|
||||
webTestClient.get()
|
||||
.uri("/?language=en")
|
||||
.exchange()
|
||||
.expectStatus()
|
||||
.isOk()
|
||||
.expectBody(String.class)
|
||||
.isEqualTo("""
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Title</title>
|
||||
</head>
|
||||
<body>
|
||||
index
|
||||
<div>en</div>
|
||||
<div>Welcome to the index</div>
|
||||
</body>
|
||||
</html>
|
||||
""");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldUseDefaultWhenLanguageNotSupport() {
|
||||
themeResolver.setThemeContextFunction(request -> createDefaultContext());
|
||||
webTestClient.get()
|
||||
.uri("/index?language=foo")
|
||||
.exchange()
|
||||
.expectStatus()
|
||||
.isOk()
|
||||
.expectBody(String.class)
|
||||
.isEqualTo("""
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Title</title>
|
||||
</head>
|
||||
<body>
|
||||
index
|
||||
<div>foo</div>
|
||||
<div>欢迎来到首页</div>
|
||||
</body>
|
||||
</html>
|
||||
""");
|
||||
}
|
||||
|
||||
@Test
|
||||
void switchTheme() {
|
||||
themeResolver.setThemeContextFunction(request -> createDefaultContext());
|
||||
webTestClient.get()
|
||||
.uri("/index?language=zh")
|
||||
.exchange()
|
||||
.expectStatus()
|
||||
.isOk()
|
||||
.expectBody(String.class)
|
||||
.isEqualTo("""
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Title</title>
|
||||
</head>
|
||||
<body>
|
||||
index
|
||||
<div>zh</div>
|
||||
<div>欢迎来到首页</div>
|
||||
</body>
|
||||
</html>
|
||||
""");
|
||||
|
||||
// For other theme
|
||||
themeResolver.setThemeContextFunction(request -> createOtherContext());
|
||||
webTestClient.get()
|
||||
.uri("/index?language=zh")
|
||||
.exchange()
|
||||
.expectBody(String.class)
|
||||
.isEqualTo("""
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Other theme title</title>
|
||||
</head>
|
||||
<body>
|
||||
<p>Other 首页</p>
|
||||
</body>
|
||||
</html>
|
||||
""");
|
||||
webTestClient.get()
|
||||
.uri("/index?language=en")
|
||||
.exchange()
|
||||
.expectBody(String.class)
|
||||
.isEqualTo("""
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Other theme title</title>
|
||||
</head>
|
||||
<body>
|
||||
<p>other index</p>
|
||||
</body>
|
||||
</html>
|
||||
""");
|
||||
}
|
||||
|
||||
ThemeContext createDefaultContext() {
|
||||
return ThemeContext.builder()
|
||||
.name("default")
|
||||
.path(Paths.get(defaultThemeUrl.getPath()))
|
||||
.active(true)
|
||||
.build();
|
||||
}
|
||||
|
||||
ThemeContext createOtherContext() {
|
||||
return ThemeContext.builder()
|
||||
.name("other")
|
||||
.path(Paths.get(otherThemeUrl.getPath()))
|
||||
.active(false)
|
||||
.build();
|
||||
}
|
||||
|
||||
@TestConfiguration
|
||||
static class MessageResolverConfig {
|
||||
@Bean
|
||||
RouterFunction<ServerResponse> routeTestIndex() {
|
||||
return RouterFunctions
|
||||
.route(RequestPredicates.GET("/").or(RequestPredicates.GET("/index"))
|
||||
.and(RequestPredicates.accept(MediaType.TEXT_HTML)),
|
||||
request -> ServerResponse.ok().render("index"));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -18,6 +18,7 @@ spring:
|
|||
show-sql: true
|
||||
|
||||
halo:
|
||||
work-dir: ${user.home}/halo-next-test
|
||||
security:
|
||||
initializer:
|
||||
disabled: true
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
index.welcome=\u6B22\u8FCE\u6765\u5230\u9996\u9875
|
|
@ -0,0 +1 @@
|
|||
index.welcome=Welcome to the index
|
|
@ -0,0 +1,12 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Title</title>
|
||||
</head>
|
||||
<body>
|
||||
index
|
||||
<div th:text="${#locale}"></div>
|
||||
<div th:text="#{index.welcome}"></div>
|
||||
</body>
|
||||
</html>
|
|
@ -0,0 +1 @@
|
|||
index.welcome=Other \u9996\u9875
|
|
@ -0,0 +1 @@
|
|||
index.welcome=other index
|
|
@ -0,0 +1,10 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Other theme title</title>
|
||||
</head>
|
||||
<body>
|
||||
<p th:text="#{index.welcome}"></p>
|
||||
</body>
|
||||
</html>
|
Loading…
Reference in New Issue