feat: add annotations expression object for thymeleaf (#3076)

#### What type of PR is this?
/kind feature
/area core
/milestone 2.1.x
#### What this PR does / why we need it:
新增操作 annotations 的表达式对象
在 thymeleaf 模板中使用示例:
```html
<p th:text="${#annotations.get(user, 'background')}"></p>
<p th:text="${#annotations.getOrDefault(user, 'background', 'default-value')}"></p>
<p th:text="${#annotations.contains(user, 'background')}"></p>
```
#### Which issue(s) this PR fixes:

Fixes #3073

#### Special notes for your reviewer:
/cc @halo-dev/sig-halo 
#### Does this PR introduce a user-facing change?
```release-note
新增 Annotations 表达式对象用于在 thymeleaf 中操作自定义模型的 annotations
```
pull/3086/head
guqing 2022-12-29 21:30:34 +08:00 committed by GitHub
parent 77dd5b24dd
commit cc891d6655
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 250 additions and 15 deletions

View File

@ -0,0 +1,40 @@
package run.halo.app.theme.dialect;
import java.util.Set;
import org.thymeleaf.context.IExpressionContext;
import org.thymeleaf.expression.IExpressionObjectFactory;
import run.halo.app.theme.dialect.expression.Annotations;
/**
* Builds the expression objects to be used by Halo dialects.
*
* @author guqing
* @since 2.0.0
*/
public class HaloExpressionObjectFactory implements IExpressionObjectFactory {
public static final String ANNOTATIONS_EXPRESSION_OBJECT_NAME = "annotations";
protected static final Set<String> ALL_EXPRESSION_OBJECT_NAMES = Set.of(
ANNOTATIONS_EXPRESSION_OBJECT_NAME);
private static final Annotations ANNOTATIONS = new Annotations();
@Override
public Set<String> getAllExpressionObjectNames() {
return ALL_EXPRESSION_OBJECT_NAMES;
}
@Override
public Object buildObject(IExpressionContext context, String expressionObjectName) {
if (ANNOTATIONS_EXPRESSION_OBJECT_NAME.equals(expressionObjectName)) {
return ANNOTATIONS;
}
return null;
}
@Override
public boolean isCacheable(String expressionObjectName) {
return true;
}
}

View File

@ -3,6 +3,8 @@ package run.halo.app.theme.dialect;
import java.util.HashSet;
import java.util.Set;
import org.thymeleaf.dialect.AbstractProcessorDialect;
import org.thymeleaf.dialect.IExpressionObjectDialect;
import org.thymeleaf.expression.IExpressionObjectFactory;
import org.thymeleaf.processor.IProcessor;
import org.thymeleaf.standard.StandardDialect;
@ -12,9 +14,13 @@ import org.thymeleaf.standard.StandardDialect;
* @author guqing
* @since 2.0.0
*/
public class HaloProcessorDialect extends AbstractProcessorDialect {
public class HaloProcessorDialect extends AbstractProcessorDialect implements
IExpressionObjectDialect {
private static final String DIALECT_NAME = "haloThemeProcessorDialect";
private static final IExpressionObjectFactory HALO_EXPRESSION_OBJECTS_FACTORY =
new HaloExpressionObjectFactory();
public HaloProcessorDialect() {
// We will set this dialect the same "dialect processor" precedence as
// the Standard Dialect, so that processor executions can interleave.
@ -31,4 +37,9 @@ public class HaloProcessorDialect extends AbstractProcessorDialect {
processors.add(new CommentElementTagProcessor(dialectPrefix));
return processors;
}
@Override
public IExpressionObjectFactory getExpressionObjectFactory() {
return HALO_EXPRESSION_OBJECTS_FACTORY;
}
}

View File

@ -0,0 +1,65 @@
package run.halo.app.theme.dialect.expression;
import java.util.Map;
import org.springframework.lang.NonNull;
import org.springframework.lang.Nullable;
import run.halo.app.theme.finders.vo.ExtensionVoOperator;
/**
* <p>Expression Object for performing annotations operations inside Halo Extra Expressions.</p>
* An object of this class is usually available in variable evaluation expressions with the name
* <code>#annotations</code>.
*
* @author guqing
* @since 2.0.2
*/
public class Annotations {
/**
* Get annotation value from extension vo.
*
* @param extension extension vo
* @param key the key of annotation
* @return annotation value if exists, otherwise null
*/
@Nullable
public String get(ExtensionVoOperator extension, String key) {
Map<String, String> annotations = extension.getMetadata().getAnnotations();
if (annotations == null) {
return null;
}
return annotations.get(key);
}
/**
* Returns the value to which the specified key is mapped, or defaultValue if
* <code>extension</code> contains no mapping for the key.
*
* @param extension extension vo
* @param key the key of annotation
* @return annotation value if exists, otherwise defaultValue
*/
@NonNull
public String getOrDefault(ExtensionVoOperator extension, String key, String defaultValue) {
Map<String, String> annotations = extension.getMetadata().getAnnotations();
if (annotations == null) {
return defaultValue;
}
return annotations.getOrDefault(key, defaultValue);
}
/**
* Check if the extension has the specified annotation.
*
* @param extension extension vo
* @param key the key of annotation
* @return true if the extension has the specified annotation, otherwise false
*/
public boolean contains(ExtensionVoOperator extension, String key) {
Map<String, String> annotations = extension.getMetadata().getAnnotations();
if (annotations == null) {
return false;
}
return annotations.containsKey(key);
}
}

View File

@ -20,7 +20,7 @@ import run.halo.app.extension.MetadataOperator;
@Builder
@ToString
@EqualsAndHashCode
public class CategoryTreeVo implements VisualizableTreeNode<CategoryTreeVo> {
public class CategoryTreeVo implements VisualizableTreeNode<CategoryTreeVo>, ExtensionVoOperator {
private MetadataOperator metadata;

View File

@ -15,7 +15,7 @@ import run.halo.app.extension.MetadataOperator;
@Value
@Builder
@EqualsAndHashCode
public class CategoryVo {
public class CategoryVo implements ExtensionVoOperator {
MetadataOperator metadata;

View File

@ -18,7 +18,7 @@ import run.halo.app.extension.MetadataOperator;
@Value
@Builder
@EqualsAndHashCode
public class CommentVo {
public class CommentVo implements ExtensionVoOperator {
@Schema(required = true)
MetadataOperator metadata;

View File

@ -0,0 +1,16 @@
package run.halo.app.theme.finders.vo;
import org.springframework.lang.NonNull;
import run.halo.app.extension.MetadataOperator;
/**
* An operator for extension value object.
*
* @author guqing
* @since 2.0.0
*/
public interface ExtensionVoOperator {
@NonNull
MetadataOperator getMetadata();
}

View File

@ -19,7 +19,7 @@ import run.halo.app.extension.MetadataOperator;
@SuperBuilder
@ToString
@EqualsAndHashCode
public class ListedPostVo {
public class ListedPostVo implements ExtensionVoOperator {
private MetadataOperator metadata;

View File

@ -19,7 +19,7 @@ import run.halo.app.extension.MetadataOperator;
@SuperBuilder
@ToString
@EqualsAndHashCode
public class ListedSinglePageVo {
public class ListedSinglePageVo implements ExtensionVoOperator {
private MetadataOperator metadata;

View File

@ -17,7 +17,7 @@ import run.halo.app.extension.MetadataOperator;
@Data
@ToString
@Builder
public class MenuItemVo implements VisualizableTreeNode<MenuItemVo> {
public class MenuItemVo implements VisualizableTreeNode<MenuItemVo>, ExtensionVoOperator {
MetadataOperator metadata;

View File

@ -18,7 +18,7 @@ import run.halo.app.extension.MetadataOperator;
@Value
@ToString
@Builder
public class MenuVo {
public class MenuVo implements ExtensionVoOperator {
MetadataOperator metadata;

View File

@ -20,7 +20,7 @@ import run.halo.app.extension.MetadataOperator;
@Builder
@ToString
@EqualsAndHashCode
public class ReplyVo {
public class ReplyVo implements ExtensionVoOperator {
@Schema(required = true)
MetadataOperator metadata;

View File

@ -10,7 +10,7 @@ import run.halo.app.extension.MetadataOperator;
*/
@Value
@Builder
public class TagVo {
public class TagVo implements ExtensionVoOperator {
MetadataOperator metadata;

View File

@ -17,7 +17,7 @@ import run.halo.app.extension.MetadataOperator;
@Value
@Builder
@ToString
public class ThemeVo {
public class ThemeVo implements ExtensionVoOperator {
MetadataOperator metadata;

View File

@ -10,7 +10,7 @@ import run.halo.app.infra.utils.JsonUtils;
@Value
@Builder
public class UserVo {
public class UserVo implements ExtensionVoOperator {
MetadataOperator metadata;
User.UserSpec spec;

View File

@ -12,6 +12,7 @@ import java.util.List;
import java.util.Map;
import java.util.Set;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
@ -26,6 +27,7 @@ import org.thymeleaf.templateresolver.StringTemplateResolver;
import org.thymeleaf.templateresource.ITemplateResource;
import org.thymeleaf.templateresource.StringTemplateResource;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.User;
import run.halo.app.core.extension.content.Post;
import run.halo.app.extension.Metadata;
import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
@ -35,6 +37,7 @@ import run.halo.app.theme.DefaultTemplateEnum;
import run.halo.app.theme.finders.PostFinder;
import run.halo.app.theme.finders.SinglePageFinder;
import run.halo.app.theme.finders.vo.PostVo;
import run.halo.app.theme.finders.vo.UserVo;
import run.halo.app.theme.router.strategy.ModelConst;
/**
@ -88,7 +91,7 @@ class HaloProcessorDialectTest {
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),
lenient().when(fetcher.fetch(eq(SystemSetting.CodeInjection.GROUP),
eq(SystemSetting.CodeInjection.class))).thenReturn(Mono.just(codeInjection));
lenient().when(applicationContext.getBean(eq(SystemConfigurableEnvironmentFetcher.class)))
@ -96,10 +99,10 @@ class HaloProcessorDialectTest {
lenient().when(fetcher.fetch(eq(SystemSetting.Seo.GROUP),
eq(SystemSetting.Seo.class))).thenReturn(Mono.empty());
when(applicationContext.getBean(eq(ExtensionComponentsFinder.class)))
lenient().when(applicationContext.getBean(eq(ExtensionComponentsFinder.class)))
.thenReturn(extensionComponentsFinder);
when(extensionComponentsFinder.getExtensions(eq(TemplateHeadProcessor.class)))
lenient().when(extensionComponentsFinder.getExtensions(eq(TemplateHeadProcessor.class)))
.thenReturn(new ArrayList<>(map.values()));
}
@ -242,6 +245,78 @@ class HaloProcessorDialectTest {
""");
}
@Nested
class AnnotationExpressionObjectFactoryTest {
@Test
void getWhenAnnotationsIsNull() {
Context context = getContext();
context.setVariable("user", createUser());
String result = templateEngine.process("annotationsGetExpression", context);
assertThat(result).isEqualTo("<p></p>\n");
}
@Test
void getWhenAnnotationsExists() {
Context context = getContext();
UserVo user = createUser();
user.getMetadata().setAnnotations(Map.of("background", "fake-background"));
context.setVariable("user", user);
String result = templateEngine.process("annotationsGetExpression", context);
assertThat(result).isEqualTo("<p>fake-background</p>\n");
}
@Test
void getOrDefaultWhenAnnotationsIsNull() {
Context context = getContext();
UserVo user = createUser();
user.getMetadata().setAnnotations(Map.of("background", "red"));
context.setVariable("user", user);
String result = templateEngine.process("annotationsGetOrDefaultExpression", context);
assertThat(result).isEqualTo("<p>red</p>\n");
}
@Test
void getOrDefaultWhenAnnotationsExists() {
Context context = getContext();
context.setVariable("user", createUser());
String result = templateEngine.process("annotationsGetOrDefaultExpression", context);
assertThat(result).isEqualTo("<p>default-value</p>\n");
}
@Test
void containsWhenAnnotationsIsNull() {
Context context = getContext();
context.setVariable("user", createUser());
String result = templateEngine.process("annotationsContainsExpression", context);
assertThat(result).isEqualTo("<p>false</p>\n");
}
@Test
void containsWhenAnnotationsIsNotNull() {
Context context = getContext();
UserVo user = createUser();
user.getMetadata().setAnnotations(Map.of("background", ""));
context.setVariable("user", user);
String result = templateEngine.process("annotationsContainsExpression", context);
assertThat(result).isEqualTo("<p>true</p>\n");
}
UserVo createUser() {
User user = new User();
user.setMetadata(new Metadata());
user.getMetadata().setName("fake-user");
user.setSpec(new User.UserSpec());
return UserVo.from(user);
}
}
private Context getContext() {
Context context = new Context();
context.setVariable(
@ -266,6 +341,16 @@ class HaloProcessorDialectTest {
if (template.equals("seo")) {
return new StringTemplateResource(seoTemplate());
}
if (template.equals("annotationsGetExpression")) {
return new StringTemplateResource(annotationsGetExpression());
}
if (template.equals("annotationsGetOrDefaultExpression")) {
return new StringTemplateResource(annotationsGetOrDefaultExpression());
}
if (template.equals("annotationsContainsExpression")) {
return new StringTemplateResource(annotationsContainsExpression());
}
return null;
}
@ -316,5 +401,23 @@ class HaloProcessorDialectTest {
</html>
""";
}
private String annotationsGetExpression() {
return """
<p th:text="${#annotations.get(user, 'background')}"></p>
""";
}
private String annotationsGetOrDefaultExpression() {
return """
<p th:text="${#annotations.getOrDefault(user, 'background', 'default-value')}"></p>
""";
}
private String annotationsContainsExpression() {
return """
<p th:text="${#annotations.contains(user, 'background')}"></p>
""";
}
}
}