diff --git a/src/main/java/run/halo/app/infra/SystemSetting.java b/src/main/java/run/halo/app/infra/SystemSetting.java index 0e7efa64c..a44d93a7a 100644 --- a/src/main/java/run/halo/app/infra/SystemSetting.java +++ b/src/main/java/run/halo/app/infra/SystemSetting.java @@ -27,4 +27,15 @@ public class SystemSetting { private String post; private String tags; } + + @Data + public static class CodeInjection { + public static final String GROUP = "codeInjection"; + + private String globalHead; + + private String contentHead; + + private String footer; + } } diff --git a/src/main/java/run/halo/app/theme/TemplateEngineManager.java b/src/main/java/run/halo/app/theme/TemplateEngineManager.java index f61812571..d4f6529cf 100644 --- a/src/main/java/run/halo/app/theme/TemplateEngineManager.java +++ b/src/main/java/run/halo/app/theme/TemplateEngineManager.java @@ -12,6 +12,7 @@ import org.thymeleaf.spring6.ISpringWebFluxTemplateEngine; import org.thymeleaf.templateresolver.FileTemplateResolver; import org.thymeleaf.templateresolver.ITemplateResolver; 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.message.ThemeMessageResolver; @@ -80,6 +81,7 @@ public class TemplateEngineManager { var mainResolver = haloTemplateResolver(); mainResolver.setPrefix(theme.getPath() + "/templates/"); engine.addTemplateResolver(mainResolver); + engine.addDialect(new HaloProcessorDialect()); templateResolvers.orderedStream().forEach(engine::addTemplateResolver); dialects.orderedStream().forEach(engine::addDialect); diff --git a/src/main/java/run/halo/app/theme/dialect/GlobalHeadInjectionProcessor.java b/src/main/java/run/halo/app/theme/dialect/GlobalHeadInjectionProcessor.java new file mode 100644 index 000000000..c4c3929e0 --- /dev/null +++ b/src/main/java/run/halo/app/theme/dialect/GlobalHeadInjectionProcessor.java @@ -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 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 '
' 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 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 getTemplateHeadProcessors(ApplicationContext ctx) { + return ctx.getBeansOfType(TemplateHeadProcessor.class) + .values(); + } +} diff --git a/src/main/java/run/halo/app/theme/dialect/HaloProcessorDialect.java b/src/main/java/run/halo/app/theme/dialect/HaloProcessorDialect.java new file mode 100644 index 000000000..7a8cb77dd --- /dev/null +++ b/src/main/java/run/halo/app/theme/dialect/HaloProcessorDialect.java @@ -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 getProcessors(String dialectPrefix) { + final Set processors = new HashSet(); + // add more processors + processors.add(new GlobalHeadInjectionProcessor(dialectPrefix)); + processors.add(new TemplateFooterElementTagProcessor(dialectPrefix)); + return processors; + } +} diff --git a/src/main/java/run/halo/app/theme/dialect/PostTemplateHeadProcessor.java b/src/main/java/run/halo/app/theme/dialect/PostTemplateHeadProcessor.java new file mode 100644 index 000000000..1ccf15570 --- /dev/null +++ b/src/main/java/run/halo/app/theme/dialect/PostTemplateHeadProcessor.java @@ -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; + +/** + *

The head html snippet injection processor for post template.

+ * + * @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 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> htmlMetas = postVo.getHtmlMetas(); + String metaHtml = headMetaBuilder(htmlMetas); + IModelFactory modelFactory = context.getModelFactory(); + model.add(modelFactory.createText(metaHtml)); + }) + .then(); + } + + private String headMetaBuilder(List> htmlMetas) { + StringBuilder sb = new StringBuilder(); + for (Map htmlMeta : htmlMetas) { + sb.append(" { + 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); + } +} \ No newline at end of file diff --git a/src/main/java/run/halo/app/theme/dialect/TemplateFooterElementTagProcessor.java b/src/main/java/run/halo/app/theme/dialect/TemplateFooterElementTagProcessor.java new file mode 100644 index 000000000..a126f03b2 --- /dev/null +++ b/src/main/java/run/halo/app/theme/dialect/TemplateFooterElementTagProcessor.java @@ -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; + +/** + *

Footer element tag processor.

+ *

Replace the footer tag <halo:footer /> with the contents of the footer + * field of the global configuration item.

+ * + * @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(); + } +} diff --git a/src/main/java/run/halo/app/theme/dialect/TemplateGlobalHeadProcessor.java b/src/main/java/run/halo/app/theme/dialect/TemplateGlobalHeadProcessor.java new file mode 100644 index 000000000..c427daf16 --- /dev/null +++ b/src/main/java/run/halo/app/theme/dialect/TemplateGlobalHeadProcessor.java @@ -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; + +/** + *

Global custom head snippet injection for theme global setting.

+ * + * @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 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 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); + } +} diff --git a/src/main/java/run/halo/app/theme/dialect/TemplateHeadProcessor.java b/src/main/java/run/halo/app/theme/dialect/TemplateHeadProcessor.java new file mode 100644 index 000000000..6f8312561 --- /dev/null +++ b/src/main/java/run/halo/app/theme/dialect/TemplateHeadProcessor.java @@ -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 head tag snippet injection processor. + * + * @author guqing + * @since 2.0.0 + */ +@FunctionalInterface +public interface TemplateHeadProcessor { + + Mono process(ITemplateContext context, IModel model, + IElementModelStructureHandler structureHandler); +} diff --git a/src/main/java/run/halo/app/theme/router/PermalinkPatternProvider.java b/src/main/java/run/halo/app/theme/router/PermalinkPatternProvider.java index 3bde4b365..122ee7a17 100644 --- a/src/main/java/run/halo/app/theme/router/PermalinkPatternProvider.java +++ b/src/main/java/run/halo/app/theme/router/PermalinkPatternProvider.java @@ -28,9 +28,10 @@ public class PermalinkPatternProvider { return client.fetch(ConfigMap.class, SystemSetting.SYSTEM_CONFIG) .map(configMap -> { Map data = configMap.getData(); - String routeRulesJson = data.get(SystemSetting.ThemeRouteRules.GROUP); - return JsonUtils.jsonToObject(routeRulesJson, SystemSetting.ThemeRouteRules.class); + return data.get(SystemSetting.ThemeRouteRules.GROUP); }) + .map(routeRulesJson -> JsonUtils.jsonToObject(routeRulesJson, + SystemSetting.ThemeRouteRules.class)) .orElseGet(() -> { SystemSetting.ThemeRouteRules themeRouteRules = new SystemSetting.ThemeRouteRules(); themeRouteRules.setArchives("archives"); diff --git a/src/main/resources/extensions/system-configurable-configmap.yaml b/src/main/resources/extensions/system-configurable-configmap.yaml index 3950dda76..7daabf4ce 100644 --- a/src/main/resources/extensions/system-configurable-configmap.yaml +++ b/src/main/resources/extensions/system-configurable-configmap.yaml @@ -9,8 +9,13 @@ data: } routeRules: | { - "categories": "/categories", - "archives": "/archives", + "categories": "categories", + "archives": "archives", "post": "/archives/{slug}", - "tags": "/tags" + "tags": "tags" + } + codeInjection: | + { + "globalHead": "", + "footer": "" } \ No newline at end of file diff --git a/src/test/java/run/halo/app/theme/dialect/HaloProcessorDialectTest.java b/src/test/java/run/halo/app/theme/dialect/HaloProcessorDialectTest.java new file mode 100644 index 000000000..0c11f35b3 --- /dev/null +++ b/src/test/java/run/halo/app/theme/dialect/HaloProcessorDialectTest.java @@ -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 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(""); + codeInjection.setGlobalHead(""); + codeInjection.setFooter("
hello this is global 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(""" + + + + + Index + + + +

index

+ + + + + """); + } + + @Test + void contentHeadAndFooterAndPostProcessors() { + Context context = getContext(); + context.setVariable("name", "fake-post"); + + List> 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(""" + + + + + Post + + + + + + +

post

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

index

+ + """); + } + + private String postTemplate() { + return commonTemplate().formatted("Post", """ +

post

+ + """); + } + + private String commonTemplate() { + return """ + + + + + %s + + + %s + + + """; + } + } +} \ No newline at end of file