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
guqing 2022-08-02 17:04:13 +08:00 committed by GitHub
parent bd6c2a544b
commit 3302ce68c9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 2928 additions and 26 deletions

View File

@ -14,5 +14,4 @@ public class HaloConfiguration {
builder.serializationInclusion(JsonInclude.Include.NON_NULL);
};
}
}
}

View File

@ -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();
}
}

View File

@ -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("*"));

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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());
}
}

View File

@ -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;
}
}

View File

@ -28,5 +28,4 @@ public class HaloProperties {
private final ExtensionProperties extension = new ExtensionProperties();
private final SecurityProperties security = new SecurityProperties();
}

View File

@ -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);
}
}

View File

@ -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);

View File

@ -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();
}
}
}

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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;
}

View File

@ -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);
}
}

View File

@ -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));
}
}
}
}

View File

@ -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;
}
}

View File

@ -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&aacute;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

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -0,0 +1,9 @@
apiVersion: v1alpha1
kind: "ConfigMap"
metadata:
name: system
data:
theme: |
{
"active": "default"
}

View File

@ -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);
}
}

View File

@ -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();
}
}

View File

@ -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));
}
}

View File

@ -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();
}
}

View File

@ -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"));
}
}
}

View File

@ -18,6 +18,7 @@ spring:
show-sql: true
halo:
work-dir: ${user.home}/halo-next-test
security:
initializer:
disabled: true

View File

@ -0,0 +1 @@
index.welcome=\u6B22\u8FCE\u6765\u5230\u9996\u9875

View File

@ -0,0 +1 @@
index.welcome=Welcome to the index

View File

@ -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>

View File

@ -0,0 +1 @@
index.welcome=Other \u9996\u9875

View File

@ -0,0 +1 @@
index.welcome=other index

View File

@ -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>