feat: support code snippet injection for theme template (#2377)

<!--  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
/milestone 2.0
/area core
<!--
添加其中一个类别:
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:
主题模板渲染时支持代码片段自动注入

如何测试:
<img width="644" alt="image" src="https://user-images.githubusercontent.com/38999863/188456203-e43c56b0-a886-4feb-bc87-ed17b97ecc4d.png">

1. 文章页 head 注入
 对于文章当spec.htmlMetas字段存在数据时访问文章页面 post.html 会自动在该页面的 head 中插入 meta tag
例如
htmlMetas = [{"name":"keywords", "content": "Halo,Blog"}, {"property":"org:name", "content": "fake-name"}]
则在 post.html返回结果的 `<head></head>`中能看到
```
<meta name="keywords" content="Halo,Blog" />
<meta property="org:name" content="fake-name" />
```
2. 内容页注入
当在后台设置->代码注入的`内容页 head`中填写了内容则访问 post.html 和 page.html 时会自动注入到 head 标签中(需要主题的是 page.html 为自定义页面,该功能在另一个 PR 中还未合并,因此内容页注入目前只适用 post.html)
3. 全局 head
当在后台设置->代码注入的`全局 head` 中填写的内容,访问任意模板页面时都会自动注入到 head 标签中
4. 页脚
页脚自定注入不适合,一般由主题开发者选择位置注入,因此提供了一个`<halo:footer></halo:footer>`标签,当在模板页面使用了此标签则 后台设置->代码注入的`页脚`处填写的内容在渲染后会替换此标签

#### 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 #2376

#### 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/2382/head^2
guqing 2022-09-07 14:12:11 +08:00 committed by GitHub
parent de40a108e2
commit 53ee2ea547
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 536 additions and 5 deletions

View File

@ -27,4 +27,15 @@ public class SystemSetting {
private String post; private String post;
private String tags; private String tags;
} }
@Data
public static class CodeInjection {
public static final String GROUP = "codeInjection";
private String globalHead;
private String contentHead;
private String footer;
}
} }

View File

@ -12,6 +12,7 @@ import org.thymeleaf.spring6.ISpringWebFluxTemplateEngine;
import org.thymeleaf.templateresolver.FileTemplateResolver; import org.thymeleaf.templateresolver.FileTemplateResolver;
import org.thymeleaf.templateresolver.ITemplateResolver; import org.thymeleaf.templateresolver.ITemplateResolver;
import run.halo.app.infra.exception.NotFoundException; import run.halo.app.infra.exception.NotFoundException;
import run.halo.app.theme.dialect.HaloProcessorDialect;
import run.halo.app.theme.engine.SpringWebFluxTemplateEngine; import run.halo.app.theme.engine.SpringWebFluxTemplateEngine;
import run.halo.app.theme.message.ThemeMessageResolver; import run.halo.app.theme.message.ThemeMessageResolver;
@ -80,6 +81,7 @@ public class TemplateEngineManager {
var mainResolver = haloTemplateResolver(); var mainResolver = haloTemplateResolver();
mainResolver.setPrefix(theme.getPath() + "/templates/"); mainResolver.setPrefix(theme.getPath() + "/templates/");
engine.addTemplateResolver(mainResolver); engine.addTemplateResolver(mainResolver);
engine.addDialect(new HaloProcessorDialect());
templateResolvers.orderedStream().forEach(engine::addTemplateResolver); templateResolvers.orderedStream().forEach(engine::addTemplateResolver);
dialects.orderedStream().forEach(engine::addDialect); dialects.orderedStream().forEach(engine::addDialect);

View File

@ -0,0 +1,82 @@
package run.halo.app.theme.dialect;
import java.util.Collection;
import org.springframework.context.ApplicationContext;
import org.thymeleaf.context.ITemplateContext;
import org.thymeleaf.model.IModel;
import org.thymeleaf.model.IModelFactory;
import org.thymeleaf.processor.element.AbstractElementModelProcessor;
import org.thymeleaf.processor.element.IElementModelStructureHandler;
import org.thymeleaf.spring6.context.SpringContextUtils;
import org.thymeleaf.templatemode.TemplateMode;
/**
* Global head injection processor.
*
* @author guqing
* @since 2.0.0
*/
public class GlobalHeadInjectionProcessor extends AbstractElementModelProcessor {
/**
* Inserting tag will re-trigger this processor, in order to avoid the loop out trigger,
* this flag is required to prevent the loop problem.
*/
private static final String PROCESS_FLAG =
GlobalHeadInjectionProcessor.class.getName() + ".PROCESSED";
private static final String TAG_NAME = "head";
private static final int PRECEDENCE = 1000;
public GlobalHeadInjectionProcessor(final String dialectPrefix) {
super(
TemplateMode.HTML, // This processor will apply only to HTML mode
dialectPrefix, // Prefix to be applied to name for matching
TAG_NAME, // Tag name: match specifically this tag
false, // Apply dialect prefix to tag name
null, // No attribute name: will match by tag name
false, // No prefix to be applied to attribute name
PRECEDENCE); // Precedence (inside dialect's own precedence)
}
@Override
protected void doProcess(ITemplateContext context, IModel model,
IElementModelStructureHandler structureHandler) {
// note that this is important!!
Object processedAlready = context.getVariable(PROCESS_FLAG);
if (processedAlready != null) {
return;
}
structureHandler.setLocalVariable(PROCESS_FLAG, true);
// handle <head> tag
/*
* Obtain the Spring application context.
*/
final ApplicationContext appCtx = SpringContextUtils.getApplicationContext(context);
/*
* Create the DOM structure that will be substituting our custom tag.
* The headline will be shown inside a '<div>' tag, and so this must
* be created first and then a Text node must be added to it.
*/
final IModelFactory modelFactory = context.getModelFactory();
IModel modelToInsert = modelFactory.createModel();
// apply processors to modelToInsert
Collection<TemplateHeadProcessor> templateHeadProcessors =
getTemplateHeadProcessors(appCtx);
for (TemplateHeadProcessor processor : templateHeadProcessors) {
processor.process(context, modelToInsert, structureHandler)
.subscribe();
}
// add to target model
model.insertModel(model.size() - 1, modelToInsert);
}
private Collection<TemplateHeadProcessor> getTemplateHeadProcessors(ApplicationContext ctx) {
return ctx.getBeansOfType(TemplateHeadProcessor.class)
.values();
}
}

View File

@ -0,0 +1,32 @@
package run.halo.app.theme.dialect;
import java.util.HashSet;
import java.util.Set;
import org.thymeleaf.dialect.AbstractProcessorDialect;
import org.thymeleaf.processor.IProcessor;
import org.thymeleaf.standard.StandardDialect;
/**
* Thymeleaf processor dialect for Halo.
*
* @author guqing
* @since 2.0.0
*/
public class HaloProcessorDialect extends AbstractProcessorDialect {
private static final String DIALECT_NAME = "Halo Theme Dialect";
public HaloProcessorDialect() {
// We will set this dialect the same "dialect processor" precedence as
// the Standard Dialect, so that processor executions can interleave.
super(DIALECT_NAME, "halo", StandardDialect.PROCESSOR_PRECEDENCE);
}
@Override
public Set<IProcessor> getProcessors(String dialectPrefix) {
final Set<IProcessor> processors = new HashSet<IProcessor>();
// add more processors
processors.add(new GlobalHeadInjectionProcessor(dialectPrefix));
processors.add(new TemplateFooterElementTagProcessor(dialectPrefix));
return processors;
}
}

View File

@ -0,0 +1,61 @@
package run.halo.app.theme.dialect;
import java.util.List;
import java.util.Map;
import org.springframework.stereotype.Component;
import org.thymeleaf.context.ITemplateContext;
import org.thymeleaf.model.IModel;
import org.thymeleaf.model.IModelFactory;
import org.thymeleaf.processor.element.IElementModelStructureHandler;
import reactor.core.publisher.Mono;
import run.halo.app.theme.DefaultTemplateEnum;
import run.halo.app.theme.finders.PostFinder;
/**
* <p>The <code>head</code> html snippet injection processor for post template.</p>
*
* @author guqing
* @since 2.0.0
*/
@Component
public class PostTemplateHeadProcessor implements TemplateHeadProcessor {
private static final String POST_NAME_VARIABLE = "name";
private final PostFinder postFinder;
public PostTemplateHeadProcessor(PostFinder postFinder) {
this.postFinder = postFinder;
}
@Override
public Mono<Void> process(ITemplateContext context, IModel model,
IElementModelStructureHandler structureHandler) {
return Mono.just(context.getTemplateData().getTemplate())
.filter(this::isPostTemplate)
.map(template -> (String) context.getVariable(POST_NAME_VARIABLE))
.map(postFinder::getByName)
.doOnNext(postVo -> {
List<Map<String, String>> htmlMetas = postVo.getHtmlMetas();
String metaHtml = headMetaBuilder(htmlMetas);
IModelFactory modelFactory = context.getModelFactory();
model.add(modelFactory.createText(metaHtml));
})
.then();
}
private String headMetaBuilder(List<Map<String, String>> htmlMetas) {
StringBuilder sb = new StringBuilder();
for (Map<String, String> htmlMeta : htmlMetas) {
sb.append("<meta");
htmlMeta.forEach((k, v) -> {
sb.append(" ").append(k).append("=\"").append(v).append("\"");
});
sb.append(" />\n");
}
return sb.toString();
}
private boolean isPostTemplate(String template) {
return DefaultTemplateEnum.POST.getValue().equals(template);
}
}

View File

@ -0,0 +1,60 @@
package run.halo.app.theme.dialect;
import org.springframework.context.ApplicationContext;
import org.thymeleaf.context.ITemplateContext;
import org.thymeleaf.model.IProcessableElementTag;
import org.thymeleaf.processor.element.AbstractElementTagProcessor;
import org.thymeleaf.processor.element.IElementTagStructureHandler;
import org.thymeleaf.spring6.context.SpringContextUtils;
import org.thymeleaf.templatemode.TemplateMode;
import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
import run.halo.app.infra.SystemSetting;
/**
* <p>Footer element tag processor.</p>
* <p>Replace the footer tag <code>&#x3C;halo:footer /&#x3E;</code> with the contents of the footer
* field of the global configuration item.</p>
*
* @author guqing
* @since 2.0.0
*/
public class TemplateFooterElementTagProcessor extends AbstractElementTagProcessor {
private static final String TAG_NAME = "footer";
private static final int PRECEDENCE = 1000;
/**
* Constructor footer element tag processor with HTML mode, dialect prefix, footer tag name.
*
* @param dialectPrefix dialect prefix
*/
public TemplateFooterElementTagProcessor(final String dialectPrefix) {
super(
TemplateMode.HTML, // This processor will apply only to HTML mode
dialectPrefix, // Prefix to be applied to name for matching
TAG_NAME, // Tag name: match specifically this tag
true, // Apply dialect prefix to tag name
null, // No attribute name: will match by tag name
false, // No prefix to be applied to attribute name
PRECEDENCE); // Precedence (inside dialect's own precedence)
}
@Override
protected void doProcess(ITemplateContext context, IProcessableElementTag tag,
IElementTagStructureHandler structureHandler) {
/*
* Obtain the Spring application context.
*/
final ApplicationContext appCtx = SpringContextUtils.getApplicationContext(context);
String globalFooterText = getGlobalFooterText(appCtx);
structureHandler.replaceWith(globalFooterText, false);
}
private String getGlobalFooterText(ApplicationContext appCtx) {
SystemConfigurableEnvironmentFetcher fetcher =
appCtx.getBean(SystemConfigurableEnvironmentFetcher.class);
return fetcher.fetch(SystemSetting.CodeInjection.GROUP, SystemSetting.CodeInjection.class)
.map(SystemSetting.CodeInjection::getFooter)
.block();
}
}

View File

@ -0,0 +1,58 @@
package run.halo.app.theme.dialect;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Component;
import org.thymeleaf.context.ITemplateContext;
import org.thymeleaf.model.IModel;
import org.thymeleaf.model.IModelFactory;
import org.thymeleaf.processor.element.IElementModelStructureHandler;
import reactor.core.publisher.Mono;
import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
import run.halo.app.infra.SystemSetting;
import run.halo.app.theme.DefaultTemplateEnum;
/**
* <p>Global custom head snippet injection for theme global setting.</p>
*
* @author guqing
* @since 2.0.0
*/
@Component
public class TemplateGlobalHeadProcessor implements TemplateHeadProcessor {
private final SystemConfigurableEnvironmentFetcher fetcher;
public TemplateGlobalHeadProcessor(SystemConfigurableEnvironmentFetcher fetcher) {
this.fetcher = fetcher;
}
@Override
public Mono<Void> process(ITemplateContext context, IModel model,
IElementModelStructureHandler structureHandler) {
final IModelFactory modelFactory = context.getModelFactory();
return fetchCodeInjection()
.doOnNext(codeInjection -> {
String globalHeader = codeInjection.getGlobalHead();
if (StringUtils.isNotBlank(globalHeader)) {
model.add(modelFactory.createText(globalHeader + "\n"));
}
// add content head to model
String contentHeader = codeInjection.getContentHead();
String template = context.getTemplateData().getTemplate();
if (StringUtils.isNotBlank(contentHeader) && isContentTemplate(template)) {
model.add(modelFactory.createText(contentHeader + "\n"));
}
})
.then();
}
private Mono<SystemSetting.CodeInjection> fetchCodeInjection() {
return fetcher.fetch(SystemSetting.CodeInjection.GROUP, SystemSetting.CodeInjection.class);
}
private boolean isContentTemplate(String template) {
// TODO includes custom page template
return DefaultTemplateEnum.POST.getValue().equals(template);
}
}

View File

@ -0,0 +1,19 @@
package run.halo.app.theme.dialect;
import org.thymeleaf.context.ITemplateContext;
import org.thymeleaf.model.IModel;
import org.thymeleaf.processor.element.IElementModelStructureHandler;
import reactor.core.publisher.Mono;
/**
* Theme template <code>head</code> tag snippet injection processor.
*
* @author guqing
* @since 2.0.0
*/
@FunctionalInterface
public interface TemplateHeadProcessor {
Mono<Void> process(ITemplateContext context, IModel model,
IElementModelStructureHandler structureHandler);
}

View File

@ -28,9 +28,10 @@ public class PermalinkPatternProvider {
return client.fetch(ConfigMap.class, SystemSetting.SYSTEM_CONFIG) return client.fetch(ConfigMap.class, SystemSetting.SYSTEM_CONFIG)
.map(configMap -> { .map(configMap -> {
Map<String, String> data = configMap.getData(); Map<String, String> data = configMap.getData();
String routeRulesJson = data.get(SystemSetting.ThemeRouteRules.GROUP); return data.get(SystemSetting.ThemeRouteRules.GROUP);
return JsonUtils.jsonToObject(routeRulesJson, SystemSetting.ThemeRouteRules.class);
}) })
.map(routeRulesJson -> JsonUtils.jsonToObject(routeRulesJson,
SystemSetting.ThemeRouteRules.class))
.orElseGet(() -> { .orElseGet(() -> {
SystemSetting.ThemeRouteRules themeRouteRules = new SystemSetting.ThemeRouteRules(); SystemSetting.ThemeRouteRules themeRouteRules = new SystemSetting.ThemeRouteRules();
themeRouteRules.setArchives("archives"); themeRouteRules.setArchives("archives");

View File

@ -9,8 +9,13 @@ data:
} }
routeRules: | routeRules: |
{ {
"categories": "/categories", "categories": "categories",
"archives": "/archives", "archives": "archives",
"post": "/archives/{slug}", "post": "/archives/{slug}",
"tags": "/tags" "tags": "tags"
}
codeInjection: |
{
"globalHead": "",
"footer": ""
} }

View File

@ -0,0 +1,200 @@
package run.halo.app.theme.dialect;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.when;
import com.google.common.collect.ImmutableSortedMap;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.context.ApplicationContext;
import org.thymeleaf.IEngineConfiguration;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.Context;
import org.thymeleaf.spring6.dialect.SpringStandardDialect;
import org.thymeleaf.spring6.expression.ThymeleafEvaluationContext;
import org.thymeleaf.templateresolver.StringTemplateResolver;
import org.thymeleaf.templateresource.ITemplateResource;
import org.thymeleaf.templateresource.StringTemplateResource;
import reactor.core.publisher.Mono;
import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
import run.halo.app.infra.SystemSetting;
import run.halo.app.theme.DefaultTemplateEnum;
import run.halo.app.theme.finders.PostFinder;
import run.halo.app.theme.finders.vo.PostVo;
/**
* Tests for {@link HaloProcessorDialect}.
*
* @author guqing
* @see HaloProcessorDialect
* @see GlobalHeadInjectionProcessor
* @see PostTemplateHeadProcessor
* @see TemplateHeadProcessor
* @see TemplateGlobalHeadProcessor
* @see TemplateFooterElementTagProcessor
* @since 2.0.0
*/
@ExtendWith(MockitoExtension.class)
class HaloProcessorDialectTest {
@Mock
private ApplicationContext applicationContext;
@Mock
private PostFinder postFinder;
@Mock
private SystemConfigurableEnvironmentFetcher fetcher;
private TemplateEngine templateEngine;
@BeforeEach
void setUp() {
HaloProcessorDialect haloProcessorDialect = new HaloProcessorDialect();
templateEngine = new TemplateEngine();
templateEngine.setDialects(Set.of(haloProcessorDialect, new SpringStandardDialect()));
templateEngine.addTemplateResolver(new TestTemplateResolver());
Map<String, TemplateHeadProcessor> map = new HashMap<>();
map.put("postTemplateHeadProcessor", new PostTemplateHeadProcessor(postFinder));
map.put("templateGlobalHeadProcessor", new TemplateGlobalHeadProcessor(fetcher));
lenient().when(applicationContext.getBeansOfType(TemplateHeadProcessor.class))
.thenReturn(map);
SystemSetting.CodeInjection codeInjection = new SystemSetting.CodeInjection();
codeInjection.setContentHead("<meta name=\"content-head-test\" content=\"test\" />");
codeInjection.setGlobalHead("<meta name=\"global-head-test\" content=\"test\" />");
codeInjection.setFooter("<footer>hello this is global footer.</footer>");
when(fetcher.fetch(eq(SystemSetting.CodeInjection.GROUP),
eq(SystemSetting.CodeInjection.class))).thenReturn(Mono.just(codeInjection));
when(applicationContext.getBean(SystemConfigurableEnvironmentFetcher.class))
.thenReturn(fetcher);
}
@Test
void globalHeadAndFooterProcessors() {
Context context = getContext();
String result = templateEngine.process("index", context);
assertThat(result).isEqualTo("""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Index</title>
<meta name="global-head-test" content="test" />
</head>
<body>
<p>index</p>
<div class="footer">
<footer>hello this is global footer.</footer>
</div>
</body>
</html>
""");
}
@Test
void contentHeadAndFooterAndPostProcessors() {
Context context = getContext();
context.setVariable("name", "fake-post");
List<Map<String, String>> htmlMetas = new ArrayList<>();
htmlMetas.add(ImmutableSortedMap.of("name", "post-meta-V1", "content", "post-meta-V1"));
htmlMetas.add(ImmutableSortedMap.of("name", "post-meta-V2", "content", "post-meta-V2"));
PostVo postVo = PostVo.builder()
.htmlMetas(htmlMetas)
.name("fake-post").build();
when(postFinder.getByName(eq("fake-post"))).thenReturn(postVo);
String result = templateEngine.process("post", context);
assertThat(result).isEqualTo("""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Post</title>
<meta content="post-meta-V1" name="post-meta-V1" />
<meta content="post-meta-V2" name="post-meta-V2" />
<meta name="global-head-test" content="test" />
<meta name="content-head-test" content="test" />
</head>
<body>
<p>post</p>
<div class="footer">
<footer>hello this is global footer.</footer>
</div>
</body>
</html>
""");
}
private Context getContext() {
Context context = new Context();
context.setVariable(
ThymeleafEvaluationContext.THYMELEAF_EVALUATION_CONTEXT_CONTEXT_VARIABLE_NAME,
new ThymeleafEvaluationContext(applicationContext, null));
return context;
}
static class TestTemplateResolver extends StringTemplateResolver {
@Override
protected ITemplateResource computeTemplateResource(IEngineConfiguration configuration,
String ownerTemplate, String template,
Map<String, Object> templateResolutionAttributes) {
if (template.equals(DefaultTemplateEnum.INDEX.getValue())) {
return new StringTemplateResource(indexTemplate());
}
if (template.equals(DefaultTemplateEnum.POST.getValue())) {
return new StringTemplateResource(postTemplate());
}
return null;
}
private String indexTemplate() {
return commonTemplate().formatted("Index", """
<p>index</p>
<div class="footer">
<halo:footer></halo:footer>
</div>
""");
}
private String postTemplate() {
return commonTemplate().formatted("Post", """
<p>post</p>
<div class="footer">
<halo:footer></halo:footer>
</div>
""");
}
private String commonTemplate() {
return """
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8" />
<title>%s</title>
</head>
<body>
%s
</body>
</html>
""";
}
}
}