001/* 002 * Licensed to the Apache Software Foundation (ASF) under one or more 003 * contributor license agreements. See the NOTICE file distributed with 004 * this work for additional information regarding copyright ownership. 005 * The ASF licenses this file to You under the Apache License, Version 2.0 006 * (the "License"); you may not use this file except in compliance with 007 * the License. You may obtain a copy of the License at 008 * 009 * http://www.apache.org/licenses/LICENSE-2.0 010 * 011 * Unless required by applicable law or agreed to in writing, software 012 * distributed under the License is distributed on an "AS IS" BASIS, 013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 014 * See the License for the specific language governing permissions and 015 * limitations under the License. 016 */ 017package org.apache.juneau.console.freemarker; 018 019import static org.apache.juneau.commons.utils.Shorts.*; 020 021import java.io.*; 022import java.util.*; 023import java.util.regex.*; 024 025import org.apache.juneau.console.*; 026import org.apache.juneau.rest.server.view.freemarker.*; 027 028import freemarker.core.*; 029import freemarker.template.*; 030 031/** 032 * The {@code <@theme name="…">} chrome FreeMarker directive: names the active stock theme once, in the chrome, 033 * points the chrome at that theme's shipped stylesheet, and — when a nested {@code <@token>} body is present 034 * — builds a {@link Theme} of custom overrides on top of it. 035 * 036 * <p> 037 * Its only attribute is {@code name} (optional), one of {@link ConsoleChromeMixin#BUILTIN_THEME_NAMES} 038 * ({@code open}, {@code light-red}, {@code light-brown}, {@code red}, {@code gray} — the kebab-case of the 039 * {@code Theme} constants). {@code format=}, an unknown attribute, an empty {@code name=""}, and an unknown 040 * theme name are all rejected (fail closed). There is deliberately <b>no</b> {@code default=}: if the tag is 041 * present, that <i>is</i> the theme. 042 * 043 * <h5 class='section'>Nameless form:</h5> 044 * <p> 045 * Without {@code name=}, the directive inherits the page theme as its seed: {@code <@console theme="…">} if given, 046 * else the {@link PageSpec#theme(String)} value, else {@code open}. It never conflicts with a page-spec theme, so 047 * {@code <@theme><@token …/></@theme>} customises whatever theme the page already selected. 048 * 049 * <h5 class='section'>Custom tokens:</h5> 050 * <p> 051 * The named stock palette is the <b>seed</b>: this directive opens a {@link ThemeBuildContext} holding a 052 * {@code Theme.Builder} copied from the named stock palette, renders the nested body so each {@code <@token>} folds 053 * its leaf or alias into that builder, then builds the theme. When the body declared at least one token, the 054 * theme's override block ({@link ConsoleChromeMixin#overrideBlock(Theme)}: leaves escaped, aliases verbatim) is 055 * emitted inline <i>after</i> the stock-theme {@code <link>}. A body-less {@code <@theme>} is just the stock theme. 056 * A {@code build()} failure (unknown {@code var()} target, cycle, a name on both channels) is a template error 057 * carrying {@code Theme.Builder}'s sentence. 058 * 059 * <p> 060 * A {@code <@token scheme="dark">} adds to a separate dark rule, emitted after the light block in the same inline 061 * {@code <style>}; see {@link TokenDirectiveModel}. A body with only dark tokens emits no light block. A dark alias 062 * whose target is not a light-theme token or alias, a {@link Theme#OPEN} token, a chrome role alias or another dark 063 * declaration is rejected. 064 * 065 * <h5 class='section'>Example:</h5> 066 * <p class='bftl'> 067 * <#-- Stock palette only: links juneau-theme-light-brown.css, no inline block. --> 068 * <@theme name="light-brown"/> 069 * 070 * <#-- Stock palette plus one leaf override: link, then <style>html:root{...}</style>. --> 071 * <@theme name="light-brown"><@token name="--jc-accent-selected" value="#8a5a1a"/></@theme> 072 * </p> 073 * 074 * <h5 class='section'>How the link lands:</h5> 075 * <p> 076 * {@code <@theme>} must be nested inside {@code <@console>}; it records the resolved stock-theme URL (via 077 * {@link ConsoleChromeMixin#themeAssetUrl(org.apache.juneau.rest.server.RestRequest, String)}) and the optional 078 * override block on the {@link PageCapture}, and {@code <@console>} emits both in its head cascade. A {@code <@theme>} 079 * authored outside {@code <@console>} is rejected fail-closed (E-18). 080 * 081 * @since 10.0.0 082 */ 083@SuppressWarnings({ 084 "java:S3776" // Validation and rendering chains many independent guards; splitting them adds no clarity. 085}) 086public final class ThemeDirectiveModel implements TemplateDirectiveModel { 087 088 /** The shared-variable name this directive registers under. */ 089 public static final String NAME = "theme"; 090 091 static final Set<String> ATTRS = Set.of("name"); 092 093 /** A CSS comment, stripped before matching the alias shape (the same belt the alias validation applies). */ 094 private static final Pattern COMMENT = Pattern.compile("/\\*.*?\\*/", Pattern.DOTALL); 095 096 /** The {@code var(--jc-name)} alias shape, capturing the target name. */ 097 private static final Pattern DARK_ALIAS_TARGET = Pattern.compile("^[Vv][Aa][Rr]\\(\\s*(--jc-[a-z0-9-]+)\\s*\\)$"); 098 099 ThemeDirectiveModel() {} 100 101 @Override 102 @SuppressWarnings({ 103 "unchecked" // FreeMarker's raw params Map is String-keyed by contract. 104 }) 105 public void execute(Environment env, @SuppressWarnings("rawtypes") Map params, TemplateModel[] loopVars, 106 TemplateDirectiveBody body) throws TemplateException, IOException { 107 Map<String, TemplateModel> p = params; 108 if (p.containsKey("format")) 109 throw FtlAttrLists.reject("<@theme> has no format= attribute."); 110 FtlAttrLists.rejectUnknown(p, NAME, ATTRS); 111 112 // A nameless <@theme> inherits the page theme; an explicit name="" is a mistake, not a request to inherit. 113 var named = p.containsKey("name"); 114 var name = FtlAttrLists.scalar(p, "name"); 115 if (named && name.isEmpty()) 116 throw FtlAttrLists.reject("<@theme> name= must not be empty; omit name= to inherit the page theme."); 117 if (named && ! ConsoleChromeMixin.BUILTIN_THEME_NAMES.contains(name)) 118 throw FtlAttrLists.reject("<@theme> unknown theme name '" + name + "'. Built-in themes: " 119 + String.join(", ", ConsoleChromeMixin.BUILTIN_THEME_NAMES) + "."); 120 121 var cap = PageCapture.get(env); 122 if (n(cap) || ! cap.consoleOpen) 123 throw FtlAttrLists.reject(f( 124 "<@theme%s> must be nested inside <@console>; the legacy pageThemeCss path was removed in 10.0.0.", named ? " name='" + name + "'" : "")); 125 126 var req = FreemarkerRenderScope.request(); 127 if (n(req)) 128 throw FtlAttrLists.reject("<@theme> needs FreemarkerRenderScope.request() (renderer wrap)."); 129 130 // Capture any nested <@token> overrides against the stock-palette seed. The context is cleared afterward so a 131 // stray <@token> outside a <@theme> (e.g. in a sibling directive's body) fails its own "must be nested" guard. 132 // A nameless directive seeds from the page theme: console theme=, else the PageSpec theme, else the default. 133 if (! named) { 134 var pageTheme = nn(cap.themeOrNull()) ? cap.themeOrNull() : ConsoleChromeMixin.BUILTIN_THEME_NAMES.get(0); 135 name = nn(cap.consoleTheme) ? cap.consoleTheme : pageTheme; 136 } 137 var seed = ConsoleChromeMixin.stockTheme(name); 138 var themeBuilder = Theme.create(name); 139 for (var e : seed.getTokens().entrySet()) 140 themeBuilder.token(e.getKey(), e.getValue()); 141 var ctx = new ThemeBuildContext(name, themeBuilder); 142 cap.themeBuild = ctx; 143 try (var sink = new StringWriter()) { 144 if (nn(body)) 145 body.render(sink); 146 } finally { 147 cap.themeBuild = null; 148 } 149 150 String overrideBlock = null; 151 if (ctx.anyDeclared) { 152 try { 153 var light = ctx.themeBuilder.build(); 154 checkDarkAliasTargets(ctx, light); 155 // The light block is emitted only when a light token was declared; a dark-only body carries just its 156 // dark rule, so the stock stylesheet keeps supplying every light value. 157 var block = (ctx.lightDeclared ? ConsoleChromeMixin.overrideBlock(light) : "") 158 + ConsoleChromeMixin.schemeOverrideBlock(ctx.darkTokens, ctx.darkAliases); 159 overrideBlock = block.isEmpty() ? null : block; 160 } catch (IllegalArgumentException e) { 161 // Theme.Builder.build()'s frozen sentence (unknown var() target, cycle, a name on both channels) is the 162 // diagnostic; re-surface it undecorated (see FtlAttrLists.reject). 163 throw FtlAttrLists.reject(e.getMessage()); 164 } 165 } 166 167 // Merge-checked before any field write, so a conflict leaves nothing half-applied. 168 // A nameless directive adopts the page theme rather than naming one, so there is nothing to conflict with. 169 if (named) 170 cap.mergeTheme(name); 171 else 172 cap.theme(name); 173 cap.themeCssUrl = ConsoleChromeMixin.themeAssetUrl(req, name); 174 cap.themeOverrideBlock = overrideBlock; 175 } 176 177 /** 178 * Rejects a dark alias whose {@code var()} target is not one of: a token or alias of the built light theme, 179 * a {@link Theme#OPEN} token, a role alias the chrome declares ({@link ConsoleChromeMixin#roleAliasNames()}), 180 * or another dark declaration. 181 */ 182 private static void checkDarkAliasTargets(ThemeBuildContext ctx, Theme light) { 183 for (var e : ctx.darkAliases.entrySet()) { 184 var m = DARK_ALIAS_TARGET.matcher(COMMENT.matcher(e.getValue()).replaceAll("").trim()); 185 if (! m.matches()) 186 continue; // HTT: the alias shape was already validated when the <@token> ran. 187 var target = m.group(1); 188 var known = light.getTokens().containsKey(target) || light.getAliases().containsKey(target) 189 || Theme.OPEN.getTokens().containsKey(target) || ConsoleChromeMixin.roleAliasNames().contains(target) 190 || ctx.darkTokens.containsKey(target) || ctx.darkAliases.containsKey(target); 191 if (! known) 192 throw new IllegalArgumentException(f("Dark alias '%s' references unknown token '%s'.", e.getKey(), target)); 193 } 194 } 195}