Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add supports for provide theme templates in plugin class path #4862

Merged
merged 5 commits into from
Nov 30, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions api/src/main/java/run/halo/app/theme/TemplateNameResolver.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package run.halo.app.theme;

import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;

/**
* <p>The {@link TemplateNameResolver} is used to resolve template name.</p>
* <code>Halo</code> has a theme mechanism, template files are provided by different themes, so
* we need a method to determine whether the template file exists in the activated theme and if
* it does not exist, provide a default template name.
*
* @author guqing
* @since 2.11.0
*/
public interface TemplateNameResolver {

/**
* Resolve template name if exists or default template name in classpath.
*
* @param exchange exchange to resolve theme to use
* @param name template
* @return template name if exists or default template name in classpath
*/
Mono<String> resolveTemplateNameOrDefault(ServerWebExchange exchange, String name);

/**
* Resolve template name if exists or default template given.
*
* @param exchange exchange to resolve theme to use
* @param name template name
* @param defaultName default template name to use if given template name not exists
* @return template name if exists or default template name given
*/
Mono<String> resolveTemplateNameOrDefault(ServerWebExchange exchange, String name,
String defaultName);

/**
* Determine whether the template file exists in the current theme.
*
* @param exchange exchange to resolve theme to use
* @param name template name
* @return <code>true</code> if the template file exists in the current theme, false otherwise
*/
Mono<Boolean> isTemplateAvailableInTheme(ServerWebExchange exchange, String name);
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
import reactor.core.Exceptions;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.infra.properties.HaloProperties;
import run.halo.app.theme.DefaultTemplateNameResolver;
import run.halo.app.theme.DefaultViewNameResolver;

/**
* Plugin application initializer will create plugin application context by plugin id and
Expand Down Expand Up @@ -90,9 +92,14 @@ private PluginApplicationContext createPluginApplicationContext(String pluginId)
stopWatch.stop();

beanFactory.registerSingleton("pluginContext", createPluginContext(plugin));

// TODO deprecated
beanFactory.registerSingleton("pluginWrapper", haloPluginManager.getPlugin(pluginId));

beanFactory.registerSingleton("templateNameResolver",
new DefaultTemplateNameResolver(
rootApplicationContext.getBean(DefaultViewNameResolver.class),
pluginApplicationContext));
populateSettingFetcher(pluginId, beanFactory);

log.debug("Total millis: {} ms -> {}", stopWatch.getTotalTimeMillis(),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package run.halo.app.theme;

import static run.halo.app.plugin.PluginConst.SYSTEM_PLUGIN_NAME;

import org.apache.commons.lang3.StringUtils;
import org.springframework.context.ApplicationContext;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import run.halo.app.plugin.PluginApplicationContext;

/**
* A default implementation of {@link TemplateNameResolver}, It will be provided for plugins to
* resolve template name.
*
* @author guqing
* @since 2.11.0
*/
public class DefaultTemplateNameResolver implements TemplateNameResolver {

private final ApplicationContext applicationContext;
private final ViewNameResolver viewNameResolver;

public DefaultTemplateNameResolver(ViewNameResolver viewNameResolver,
ApplicationContext applicationContext) {
this.applicationContext = applicationContext;
this.viewNameResolver = viewNameResolver;
}

@Override
public Mono<String> resolveTemplateNameOrDefault(ServerWebExchange exchange, String name) {
if (applicationContext instanceof PluginApplicationContext pluginApplicationContext) {
var pluginName = pluginApplicationContext.getPluginId();
return this.resolveTemplateNameOrDefault(exchange, name,
pluginClassPathTemplate(pluginName, name));
}
return resolveTemplateNameOrDefault(exchange, name,
pluginClassPathTemplate(SYSTEM_PLUGIN_NAME, name));
}

@Override
public Mono<String> resolveTemplateNameOrDefault(ServerWebExchange exchange, String name,
String defaultName) {
return viewNameResolver.resolveViewNameOrDefault(exchange, name, defaultName);
}

@Override
public Mono<Boolean> isTemplateAvailableInTheme(ServerWebExchange exchange, String name) {
return this.resolveTemplateNameOrDefault(exchange, name, "")
.filter(StringUtils::isNotBlank)
.hasElement();
}

String pluginClassPathTemplate(String pluginName, String templateName) {
return "plugin:" + pluginName + ":" + templateName;
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package run.halo.app.theme.router;
package run.halo.app.theme;

import java.nio.file.Files;
import lombok.AllArgsConstructor;
Expand All @@ -7,18 +7,18 @@
import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import run.halo.app.theme.ThemeResolver;

/**
* The {@link ViewNameResolver} is used to resolve view name.
* The {@link DefaultViewNameResolver} is used to resolve view name.
*
* @author guqing
* @since 2.0.0
*/
@Component
@AllArgsConstructor
public class ViewNameResolver {
public class DefaultViewNameResolver implements ViewNameResolver {
private static final String TEMPLATES = "templates";
private final ThemeResolver themeResolver;
private final ThymeleafProperties thymeleafProperties;
Expand All @@ -27,12 +27,13 @@ public class ViewNameResolver {
* Resolves view name.
* If the {@param #name} cannot be resolved to the view, the {@param #defaultName} is returned.
*/
public Mono<String> resolveViewNameOrDefault(ServerRequest request, String name,
@Override
public Mono<String> resolveViewNameOrDefault(ServerWebExchange exchange, String name,
String defaultName) {
if (StringUtils.isBlank(name)) {
return Mono.justOrEmpty(defaultName);
}
return themeResolver.getTheme(request.exchange())
return themeResolver.getTheme(exchange)
.mapNotNull(themeContext -> {
String templateResourceName = computeResourceName(name);
var resourcePath = themeContext.getPath()
Expand All @@ -43,6 +44,12 @@ public Mono<String> resolveViewNameOrDefault(ServerRequest request, String name,
.switchIfEmpty(Mono.justOrEmpty(defaultName));
}

@Override
public Mono<String> resolveViewNameOrDefault(ServerRequest request, String name,
String defaultName) {
return resolveViewNameOrDefault(request.exchange(), name, defaultName);
}

String computeResourceName(String name) {
Assert.notNull(name, "Name must not be null");
return StringUtils.endsWith(name, thymeleafProperties.getSuffix())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import java.io.FileNotFoundException;
import java.nio.file.Path;
import lombok.NonNull;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.thymeleaf.ThymeleafProperties;
import org.springframework.stereotype.Component;
Expand All @@ -17,8 +18,10 @@
import reactor.core.publisher.Mono;
import run.halo.app.infra.ExternalUrlSupplier;
import run.halo.app.infra.exception.NotFoundException;
import run.halo.app.plugin.HaloPluginManager;
import run.halo.app.theme.dialect.HaloProcessorDialect;
import run.halo.app.theme.engine.HaloTemplateEngine;
import run.halo.app.theme.engine.PluginClassloaderTemplateResolver;
import run.halo.app.theme.message.ThemeMessageResolver;

/**
Expand All @@ -45,6 +48,8 @@ public class TemplateEngineManager {

private final ExternalUrlSupplier externalUrlSupplier;

private final HaloPluginManager haloPluginManager;

private final ObjectProvider<ITemplateResolver> templateResolvers;

private final ObjectProvider<IDialect> dialects;
Expand All @@ -53,10 +58,11 @@ public class TemplateEngineManager {

public TemplateEngineManager(ThymeleafProperties thymeleafProperties,
ExternalUrlSupplier externalUrlSupplier,
ObjectProvider<ITemplateResolver> templateResolvers,
HaloPluginManager haloPluginManager, ObjectProvider<ITemplateResolver> templateResolvers,
ObjectProvider<IDialect> dialects, ThemeResolver themeResolver) {
this.thymeleafProperties = thymeleafProperties;
this.externalUrlSupplier = externalUrlSupplier;
this.haloPluginManager = haloPluginManager;
this.templateResolvers = templateResolvers;
this.dialects = dialects;
this.themeResolver = themeResolver;
Expand Down Expand Up @@ -119,6 +125,8 @@ private ISpringWebFluxTemplateEngine templateEngineGenerator(CacheKey cacheKey)
var mainResolver = haloTemplateResolver();
mainResolver.setPrefix(cacheKey.context().getPath().resolve("templates") + "/");
engine.addTemplateResolver(mainResolver);
var pluginTemplateResolver = createPluginClassloaderTemplateResolver();
engine.addTemplateResolver(pluginTemplateResolver);
// replace StandardDialect with SpringStandardDialect
engine.setDialect(new SpringStandardDialect() {
@Override
Expand All @@ -134,6 +142,19 @@ public IStandardVariableExpressionEvaluator getVariableExpressionEvaluator() {
return engine;
}

@NonNull
private PluginClassloaderTemplateResolver createPluginClassloaderTemplateResolver() {
var pluginTemplateResolver = new PluginClassloaderTemplateResolver(haloPluginManager);
pluginTemplateResolver.setPrefix(thymeleafProperties.getPrefix());
pluginTemplateResolver.setSuffix(thymeleafProperties.getSuffix());
pluginTemplateResolver.setTemplateMode(thymeleafProperties.getMode());
pluginTemplateResolver.setOrder(1);
if (thymeleafProperties.getEncoding() != null) {
pluginTemplateResolver.setCharacterEncoding(thymeleafProperties.getEncoding().name());
}
return pluginTemplateResolver;
}

FileTemplateResolver haloTemplateResolver() {
final var resolver = new FileTemplateResolver();
resolver.setTemplateMode(thymeleafProperties.getMode());
Expand Down
20 changes: 20 additions & 0 deletions application/src/main/java/run/halo/app/theme/ViewNameResolver.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package run.halo.app.theme;

import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;

/**
* The {@link ViewNameResolver} is used to resolve view name if the view name cannot be resolved
* to the view, the default view name is returned.
*
* @author guqing
* @since 2.10.2
*/
public interface ViewNameResolver {
Mono<String> resolveViewNameOrDefault(ServerWebExchange exchange, String name,
String defaultName);

Mono<String> resolveViewNameOrDefault(ServerRequest request, String name,
String defaultName);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package run.halo.app.theme.engine;

import static run.halo.app.plugin.PluginConst.SYSTEM_PLUGIN_NAME;

import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.commons.lang3.StringUtils;
import org.pf4j.PluginState;
import org.springframework.core.io.DefaultResourceLoader;
import org.springframework.lang.Nullable;
import org.thymeleaf.IEngineConfiguration;
import org.thymeleaf.spring6.templateresource.SpringResourceTemplateResource;
import org.thymeleaf.templateresolver.AbstractConfigurableTemplateResolver;
import org.thymeleaf.templateresource.ITemplateResource;
import run.halo.app.plugin.HaloPluginManager;

/**
* Plugin classloader template resolver to resolve template by plugin classloader.
*
* @author guqing
* @since 2.11.0
*/
public class PluginClassloaderTemplateResolver extends AbstractConfigurableTemplateResolver {

private final HaloPluginManager haloPluginManager;
static final Pattern PLUGIN_TEMPLATE_PATTERN =
Pattern.compile("plugin:([A-Za-z0-9\\-.]+):(.+)");

/**
* Create a new plugin classloader template resolver, not cacheable.
*
* @param haloPluginManager plugin manager must not be null
*/
public PluginClassloaderTemplateResolver(HaloPluginManager haloPluginManager) {
super();
this.haloPluginManager = haloPluginManager;
setCacheable(false);
}

@Override
protected ITemplateResource computeTemplateResource(
final IEngineConfiguration configuration, final String ownerTemplate, final String template,
final String resourceName, final String characterEncoding,
final Map<String, Object> templateResolutionAttributes) {
var matchResult = matchPluginTemplate(ownerTemplate, template);
if (!matchResult.matches()) {
return null;
}
String pluginName = matchResult.pluginName();
var classloader = getClassloaderByPlugin(pluginName);
if (classloader == null) {
return null;
}

var templateName = matchResult.templateName();
var ownerTemplateName = matchResult.ownerTemplateName();

String handledResourceName = computeResourceName(configuration, ownerTemplateName,
templateName, getPrefix(), getSuffix(), getForceSuffix(), getTemplateAliases(),
templateResolutionAttributes);

var resource = new DefaultResourceLoader(classloader)
.getResource(handledResourceName);
return new SpringResourceTemplateResource(resource, characterEncoding);
}

MatchResult matchPluginTemplate(String ownerTemplate, String template) {
boolean matches = false;
String pluginName = null;
String templateName = template;
String ownerTemplateName = ownerTemplate;
if (StringUtils.isNotBlank(ownerTemplate)) {
Matcher ownerTemplateMatcher = PLUGIN_TEMPLATE_PATTERN.matcher(ownerTemplate);
if (ownerTemplateMatcher.matches()) {
matches = true;
pluginName = ownerTemplateMatcher.group(1);
ownerTemplateName = ownerTemplateMatcher.group(2);
}
}
Matcher templateMatcher = PLUGIN_TEMPLATE_PATTERN.matcher(template);
if (templateMatcher.matches()) {
matches = true;
pluginName = templateMatcher.group(1);
templateName = templateMatcher.group(2);
}
return new MatchResult(pluginName, ownerTemplateName, templateName, matches);
}

record MatchResult(String pluginName, String ownerTemplateName, String templateName,
boolean matches) {
}

@Nullable
private ClassLoader getClassloaderByPlugin(String pluginName) {
if (SYSTEM_PLUGIN_NAME.equals(pluginName)) {
return this.getClass().getClassLoader();
}
var pluginWrapper = haloPluginManager.getPlugin(pluginName);
if (pluginWrapper == null || !PluginState.STARTED.equals(pluginWrapper.getPluginState())) {
return null;
}
return pluginWrapper.getPluginClassLoader();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import run.halo.app.infra.AnonymousUserConst;
import run.halo.app.infra.exception.NotFoundException;
import run.halo.app.theme.DefaultTemplateEnum;
import run.halo.app.theme.ViewNameResolver;
import run.halo.app.theme.finders.PostPublicQueryService;
import run.halo.app.theme.finders.SinglePageConversionService;
import run.halo.app.theme.finders.vo.ContributorVo;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
import run.halo.app.extension.controller.Reconciler;
import run.halo.app.infra.exception.NotFoundException;
import run.halo.app.theme.DefaultTemplateEnum;
import run.halo.app.theme.ViewNameResolver;
import run.halo.app.theme.finders.SinglePageFinder;

/**
Expand Down
Loading