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 &mdash; when a nested {@code <@token>} body is present
034 * &mdash; 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} &mdash; 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 *    &lt;#-- Stock palette only: links juneau-theme-light-brown.css, no inline block. --&gt;
068 *    &lt;@theme name="light-brown"/&gt;
069 *
070 *    &lt;#-- Stock palette plus one leaf override: link, then &lt;style&gt;html:root{...}&lt;/style&gt;. --&gt;
071 *    &lt;@theme name="light-brown"&gt;&lt;@token name="--jc-accent-selected" value="#8a5a1a"/&gt;&lt;/@theme&gt;
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}