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; 018 019import static org.apache.juneau.commons.utils.Shorts.*; 020 021import java.io.*; 022import java.nio.charset.*; 023import java.util.*; 024import java.util.concurrent.*; 025import java.util.concurrent.atomic.*; 026import java.util.stream.*; 027 028import org.apache.juneau.commons.utils.*; 029import org.apache.juneau.http.*; 030import org.apache.juneau.http.response.*; 031import org.apache.juneau.rest.server.*; 032import org.apache.juneau.rest.server.util.*; 033 034/** 035 * Mixin that serves the admin-console chrome stylesheet at {@code /juneau-console/chrome.css}. 036 * 037 * <p> 038 * Compose into a host resource via {@link Rest#mixins() @Rest(mixins=ConsoleChromeMixin.class)} (see 039 * {@code DataTablesMixin} in the {@code juneau-console-datatables} module for the identical asset-serving 040 * pattern this mirrors — not linked here since that module is not a dependency of this one). The served 041 * response is the static 042 * structural {@code chrome.css} (shipped in this module's classpath) with {@link Theme#OPEN}'s block, then the 043 * selected stock theme's override block if one other than {@code open} was selected, appended as 044 * {@code html:root{}} blocks. 045 * 046 * <h5 class='section'>Mount styles:</h5> 047 * <p> 048 * Both of the following arrangements serve the stylesheets at the {@link #CHROME_CSS_PATH} / {@link #THEME_CSS_DIR} 049 * URLs with no path juggling by the host resource: 050 * <ul> 051 * <li><b>Composed</b> — the host resource is mounted wherever the application already mounts it (e.g. 052 * {@code /rest/*}) and the stylesheets hang off that mount, at 053 * <code><host-mount>/juneau-console/chrome.css</code>. 054 * <li><b>Standalone</b> — the host resource is registered with the servlet container at url-pattern 055 * {@code /juneau-console/*} so the stylesheets sit at a fixed site-root URL, independent of which page or tab 056 * rendered the referencing {@code <link>}. 057 * </ul> 058 * <p> 059 * The two arrangements need different operation paths, because a container mount at {@code /juneau-console/*} 060 * reports {@code servletPath="/juneau-console"} and Juneau resolves an operation's path against the request URI 061 * with {@code contextPath + servletPath} already removed — leaving only {@code /chrome.css} to match. 062 * Each operation below therefore declares <i>both</i> its prefixed path and the same path minus the 063 * {@code /juneau-console} prefix, so whichever one the arrangement leaves to be matched resolves. 064 * 065 * <h5 class='section'>Theme precedence</h5> 066 * <p>{@link Builder#theme(String)} if set, else {@link Theme#OPEN}. {@link Theme#OPEN}'s block is always appended 067 * first; a selected non-{@code open} stock theme adds exactly one more block. 068 * 069 * <h5 class='section'>Stylesheet load-order band:</h5> 070 * <p> 071 * At equal cascade specificity, whichever of {@code juneau-views.css} and this class's {@code chrome.css} is 072 * declared later in a page's {@code <head>} wins — the exact ambiguity the {@code overrideBlock} theme block 073 * below is raised to {@code html:root} to make irrelevant. Juneau's own in-tree page emitters (the examples 074 * module) don't rely on that fix alone: they declare a five-position stylesheet/script load-order band, 075 * enforced by a build-time test, so their own asset order is never left to chance in the first place: 076 * <ol> 077 * <li>vendor stylesheets 078 * <li>{@code juneau-views.css} — the views base layer 079 * <li>first-party widget stylesheets that build on the views layer, e.g. {@code juneau-calendar.css} 080 * <li>the consumer theme, e.g. this class's {@code chrome.css} 081 * <li>page-local {@code <style>} 082 * </ol> 083 * <p> 084 * That guard reaches only the pages Juneau itself emits. It says nothing about, and claims no control over, an 085 * arbitrary host application's own document — a consumer is free to link {@code chrome.css} and 086 * {@code juneau-views.css} in either order, which is precisely why the {@code html:root} fix exists: so the 087 * active theme wins regardless of which order the consumer's links happen to appear in. 088 * 089 * <h5 class='section'>Example:</h5> 090 * <p class='bjava'> 091 * <jc>// Stock chrome.css (Theme.OPEN tokens), mounted standalone at /juneau-console/*.</jc> 092 * <ja>@Rest</ja>(mixins=ConsoleChromeMixin.<jk>class</jk>) 093 * <jk>public class</jk> ConsoleAssetsRest <jk>extends</jk> BasicRestServlet { 094 * <ja>@Bean</ja> 095 * <jk>public</jk> ConsoleChromeMixin consoleChrome() { 096 * <jk>return</jk> ConsoleChromeMixin.<jsm>create</jsm>().build(); 097 * } 098 * } 099 * 100 * <jc>// Bake a different stock palette into the served chrome.css.</jc> 101 * ConsoleChromeMixin.<jsm>create</jsm>().theme(<js>"gray"</js>).build(); 102 * </p> 103 * 104 * <p> 105 * Custom palettes aren't configured here. Author them in the FreeMarker chrome with {@code <@theme>}/{@code <@token>} 106 * (see the {@link Theme} example), which emits the stock-theme {@code <link>} plus an inline override block. 107 * 108 * @since 10.0.0 109 */ 110// @formatter:off 111@Rest 112@SuppressWarnings({ 113 "java:S125", // Comments are explanatory; they are not commented-out code. 114 "java:S1192" // Duplicated "html:root{" selector text is a CSS protocol fragment; a constant would obscure the emitter. 115}) 116public class ConsoleChromeMixin { 117 118 /** The URL path at which the chrome stylesheet is served (relative to the host mount). */ 119 public static final String CHROME_CSS_PATH = "/juneau-console/chrome.css"; 120 121 /** {@link #CHROME_CSS_PATH} minus the {@code /juneau-console} prefix - see the class javadoc's mount-styles section. */ 122 static final String CHROME_CSS_PATH_UNPREFIXED = "/chrome.css"; 123 124 /** Classpath location of the shipped structural stylesheet. */ 125 static final String CHROME_CSS_RESOURCE = "/org/apache/juneau/console/chrome.css"; 126 127 /** 128 * The URL directory the shipped stock-theme stylesheets are served under (relative to the host mount), e.g. 129 * {@code /juneau-console/themes/juneau-theme-light-red.css}. A subdirectory of the chrome mount so a 130 * templated {@code {file}} path cannot collide with the fixed {@link #CHROME_CSS_PATH} endpoint. The FTL 131 * {@code <@theme name="…"/>} directive links these; see {@link #themeAssetUrl(RestRequest, String)}. 132 */ 133 public static final String THEME_CSS_DIR = "/juneau-console/themes/"; 134 135 /** {@link #THEME_CSS_DIR} minus the {@code /juneau-console} prefix - see the class javadoc's mount-styles section. */ 136 static final String THEME_CSS_DIR_UNPREFIXED = "/themes/"; 137 138 /** Classpath directory the shipped theme stylesheets live in (same package as {@link #CHROME_CSS_RESOURCE}). */ 139 static final String THEME_CSS_RESOURCE_DIR = "/org/apache/juneau/console/"; 140 141 /** The stock palettes by FTL/builder name, in documented order; the single source for the name list and file allowlist. */ 142 private static final Map<String,Theme> STOCK_THEMES = stockThemes(); 143 144 /** 145 * The built-in stock-theme names - the kebab-case of the {@link Theme} constants ({@link Theme#OPEN}, 146 * {@link Theme#LIGHT_RED}, {@link Theme#LIGHT_BROWN}, {@link Theme#RED}, {@link Theme#GRAY}). Each maps to a 147 * shipped {@code juneau-theme-<name>.css} stylesheet. Consumed by the FTL {@code <@theme>} directive to fail an 148 * unknown name closed, and here to bound the served {@code {file}} to a known stock theme. 149 */ 150 public static final List<String> BUILTIN_THEME_NAMES = List.copyOf(STOCK_THEMES.keySet()); 151 152 /** 153 * The colour-scheme pin values, in documented order. 154 * 155 * <p> 156 * {@code auto} (the default) follows the browser's {@code prefers-color-scheme}; {@code light} and {@code dark} 157 * pin the page to that scheme. Accepted by {@code <@console scheme="…">}, {@code PageSpec.scheme(String)} and 158 * {@link #schemeHead(RestRequest, String, boolean)}. 159 */ 160 public static final List<String> SCHEMES = List.of("auto", "light", "dark"); 161 162 /** 163 * The inline pre-paint script that picks the page's colour scheme before first paint. 164 * 165 * <p> 166 * It sets {@code data-jc-scheme} on the root element from, in order: the visitor's stored choice (only when the 167 * root carries {@code data-jc-scheme-toggle}), the server pin ({@code data-jc-scheme-pin}), then the browser's 168 * {@code prefers-color-scheme}. It never throws: storage and media-query failures fall through to the next 169 * source, ending at {@code light}. 170 * 171 * <p> 172 * The text is a byte-for-byte constant so {@link #SCHEME_SCRIPT_CSP_HASH} is stable. Emit it as the first child 173 * of {@code <head>}, ahead of every stylesheet. 174 */ 175 public static final String SCHEME_SCRIPT = String.join("\n", 176 "(function(d){var e=d.documentElement,k=\"juneau-console:scheme\",s=null;", 177 "if(e.hasAttribute(\"data-jc-scheme-toggle\")){try{s=localStorage.getItem(k)}catch(x){}}", 178 "if(s!==\"light\"&&s!==\"dark\"){s=e.getAttribute(\"data-jc-scheme-pin\");}", 179 "if(s!==\"light\"&&s!==\"dark\"){try{s=matchMedia(\"(prefers-color-scheme: dark)\").matches?\"dark\":\"light\"}catch(x){s=\"light\"}}", 180 "e.setAttribute(\"data-jc-scheme\",s);})(document);"); 181 182 /** 183 * The CSP source expression for {@link #SCHEME_SCRIPT}: {@code 'sha256-<base64>'} over its UTF-8 bytes. 184 * 185 * <p> 186 * Add it to a {@code script-src} directive to allow the pre-paint script without a nonce. It covers only the 187 * script. The console shell's inline {@code <style>} (theme override blocks) still needs the request nonce or 188 * {@code 'unsafe-inline'} in {@code style-src}. 189 */ 190 public static final String SCHEME_SCRIPT_CSP_HASH = sha256Source(SCHEME_SCRIPT); 191 192 /** The served stock stylesheet file names, derived from {@link #BUILTIN_THEME_NAMES}. */ 193 private static final Set<String> ALLOWED_THEME_FILES = Stream.concat( 194 BUILTIN_THEME_NAMES.stream().map(n -> "juneau-theme-" + n + ".css"), 195 // The shared dark neutral layer is a served theme file but not a selectable theme, so it is not in 196 // BUILTIN_THEME_NAMES (theme="dark" must keep failing validation). 197 Stream.of("juneau-theme-dark.css")) 198 .collect(Collectors.toUnmodifiableSet()); 199 200 private static Map<String,Theme> stockThemes() { 201 Map<String,Theme> m = m( 202 "open", Theme.OPEN, 203 "light-red", Theme.LIGHT_RED, 204 "light-brown", Theme.LIGHT_BROWN, 205 "red", Theme.RED, 206 "gray", Theme.GRAY 207 ); 208 return u(m); 209 } 210 211 /** Content type emitted for the chrome stylesheet. */ 212 static final String CONTENT_TYPE = "text/css;charset=utf-8"; 213 214 /** {@code Cache-Control} header emitted for the chrome stylesheet (1 day). */ 215 static final String CACHE_CONTROL = "max-age=86400, public"; 216 217 /** Context-root-absolute path of the console shell script. */ 218 public static final String CONSOLE_JS_PATH = "/juneau-console/juneau-console.js"; 219 220 /** {@link #CONSOLE_JS_PATH} minus the {@code /juneau-console} prefix - see the class javadoc's mount-styles section. */ 221 public static final String CONSOLE_JS_PATH_UNPREFIXED = "/juneau-console.js"; 222 223 /** Classpath location of the console shell script. */ 224 public static final String CONSOLE_JS_RESOURCE = "/org/apache/juneau/console/juneau-console.js"; 225 226 /** Context-root-absolute path of the count-badge script. */ 227 public static final String BADGES_JS_PATH = "/juneau-console/juneau-badges.js"; 228 229 /** {@link #BADGES_JS_PATH} minus the {@code /juneau-console} prefix. */ 230 public static final String BADGES_JS_PATH_UNPREFIXED = "/juneau-badges.js"; 231 232 /** Classpath location of the count-badge script. */ 233 public static final String BADGES_JS_RESOURCE = "/org/apache/juneau/console/juneau-badges.js"; 234 235 /** Context-root-absolute path of the chart-card runtime. */ 236 public static final String CHARTS_JS_PATH = "/juneau-console/juneau-charts.js"; 237 238 /** {@link #CHARTS_JS_PATH} minus the {@code /juneau-console} prefix. */ 239 public static final String CHARTS_JS_PATH_UNPREFIXED = "/juneau-charts.js"; 240 241 /** Classpath location of the chart-card runtime. */ 242 public static final String CHARTS_JS_RESOURCE = "/org/apache/juneau/console/juneau-charts.js"; 243 244 /** Context-root-absolute path of the job-log card runtime. */ 245 public static final String JOBLOG_JS_PATH = "/juneau-console/juneau-joblog.js"; 246 247 /** {@link #JOBLOG_JS_PATH} minus the {@code /juneau-console} prefix. */ 248 public static final String JOBLOG_JS_PATH_UNPREFIXED = "/juneau-joblog.js"; 249 250 /** Classpath location of the job-log card runtime. */ 251 public static final String JOBLOG_JS_RESOURCE = "/org/apache/juneau/console/juneau-joblog.js"; 252 253 /** Context-root-absolute path of the dashboard chrome runtime. */ 254 public static final String DASHBOARD_JS_PATH = "/juneau-console/juneau-dashboard.js"; 255 256 /** {@link #DASHBOARD_JS_PATH} minus the {@code /juneau-console} prefix. */ 257 public static final String DASHBOARD_JS_PATH_UNPREFIXED = "/juneau-dashboard.js"; 258 259 /** Classpath resource behind {@link #DASHBOARD_JS_PATH}. */ 260 public static final String DASHBOARD_JS_RESOURCE = "/org/apache/juneau/console/juneau-dashboard.js"; 261 262 /** Context-root-absolute path of the page-contract schema. */ 263 public static final String SCHEMA_PATH = "/juneau-console/juneau-page.schema.json"; 264 265 /** {@link #SCHEMA_PATH} minus the {@code /juneau-console} prefix. */ 266 static final String SCHEMA_PATH_UNPREFIXED = "/juneau-page.schema.json"; 267 268 /** Classpath location of the page-contract schema (same file as {@link PageContractSchema#RESOURCE}). */ 269 static final String SCHEMA_RESOURCE = "/" + PageContractSchema.RESOURCE; 270 271 static final String JS_CONTENT_TYPE = "text/javascript;charset=utf-8"; 272 static final String JSON_CONTENT_TYPE = "application/json;charset=utf-8"; 273 274 /** 275 * The framework-authored role-token alias derivations. 276 * 277 * <p> 278 * {@code chrome.css} consumes <i>role</i>-named tokens ({@code --jc-header-bg}, {@code --jc-surface}, 279 * {@code --jc-on-accent}, ...); {@link Theme#OPEN} (and every consumer theme) still defines the original 280 * <i>colour</i>-named leaf tokens ({@code --jc-white}, {@code --jc-border-2}, ...). This block bridges the two 281 * by deriving each role token <b>from</b> the legacy token it replaces, so a consumer's existing override of a 282 * legacy token still flows through to every derived role, and a consumer who sets a role token directly 283 * out-ranks the derived default by cascade order (this block is emitted inside the {@code Theme.OPEN} block, 284 * before any active-theme override block). 285 * 286 * <p> 287 * These aliases are <b>permanent</b> (no deprecation window) and are deliberately <i>not</i> 288 * {@link Theme#OPEN} tokens. A {@code var(--jc-*)}-valued token IS legal Theme-layer syntax — 289 * {@code Theme.Builder} recognizes it as a reference and resolves it to a concrete literal at {@code build()} 290 * time — but that resolution scope deliberately excludes these role aliases, which are appended here 291 * outside {@code Theme.OPEN}'s token map. Making them {@code Theme.OPEN} tokens instead would resolve each alias 292 * to a <i>fixed literal</i> at composition time, snapshotting the live CSS cascade (the dark-mode / 293 * user-agent overrides that reach these role tokens at use time) into a frozen value — so 294 * {@code Theme.OPEN} is kept all-literal and the aliases stay here as framework-authored literal text that is 295 * emitted verbatim (never routed through {@code CssValueGrammar}, which exists to validate <i>consumer</i> 296 * input, not the framework's own stylesheet). {@code Theme.OPEN} owns leaf values; this block owns derived 297 * values; no token name is declared by both. 298 * 299 * <p> 300 * {@code --jc-danger-text} and {@code --jc-success-text} are the text-ink roles of the danger and success 301 * colours; the fills keep {@code --jc-danger} and {@code --jc-success}. 302 * 303 * <p> 304 * {@code --jc-header-bg} (and, transitively, {@code --jc-nav-bg}) keys off {@code --jc-white}, not 305 * {@code --jc-chrome-bg}: in the light scheme the header and both page-nav rows (Page Tabs and Page Subtabs) 306 * stay white on every stock theme (the dark layer sets the header background directly), matching 307 * {@link Theme#OPEN}'s blue look. Theme accent still paints the selected-tab 308 * indicator, page-nav floor, and the rest of the page; {@code --jc-chrome-bg} remains the page-chrome 309 * fallback under {@code --jc-page-bg}, the hover fill, and dialog chrome. Pinning header/nav to 310 * {@code --jc-chrome-bg} washed those bars in light-red / light-brown / red / gray. {@code --jc-page-nav-accent} 311 * defaults to {@code --jc-accent} so a consumer can retint the page-nav floor / selected-section bar without 312 * recoloring buttons. {@code --jc-table-header-bg} also keys off {@code --jc-white} so DataTable / views-table 313 * column headers stay white (the original artwork gives {@code table.dataTable thead th} no background), not the page-chrome grey. 314 */ 315 static final String OPEN_ROLE_ALIASES = String.join("", 316 "--jc-surface:var(--jc-white);", 317 "--jc-header-bg:var(--jc-white);", 318 "--jc-nav-bg:var(--jc-header-bg);", 319 "--jc-page-nav-accent:var(--jc-accent);", 320 "--jc-control-bg:var(--jc-surface);", 321 "--jc-table-bg:var(--jc-surface);", 322 "--jc-table-row-bg:var(--jc-surface);", 323 "--jc-on-accent:var(--jc-white);", 324 "--jc-on-btn-primary:var(--jc-on-accent);", 325 "--jc-hover-bg:var(--jc-chrome-bg);", 326 // DataTable / views-table headers are white (the original artwork gives `table.dataTable thead th` no background; 327 // the white card shows through). Do not key this off --jc-chrome-bg — that page-chrome grey tints 328 // every column header and is the wrong surface (the detail-table thead grey is a different widget). 329 "--jc-table-header-bg:var(--jc-white);", 330 "--jc-control-border:var(--jc-border-2);", 331 "--jc-header-icon-text:var(--jc-header-icon-color);", 332 "--jc-btn-primary-bg:var(--jc-btn-primary);", 333 "--jc-btn-primary-bg-hover:var(--jc-btn-primary-hover);", 334 // Text roles: danger/success used as ink, so the dark layer can lighten them without touching the fills. 335 "--jc-danger-text:var(--jc-danger);", 336 "--jc-success-text:var(--jc-success);" 337 ); 338 339 /** 340 * The names of the role aliases the chrome declares (for example {@code --jc-surface} and 341 * {@code --jc-danger-text}), derived from the same declarations the stylesheet emits. 342 */ 343 private static final Set<String> ROLE_ALIAS_NAMES = Arrays.stream(OPEN_ROLE_ALIASES.split(";")) 344 .map(d -> d.substring(0, d.indexOf(':'))) 345 .collect(Collectors.toUnmodifiableSet()); 346 347 /** 348 * Returns the names of the role aliases the chrome declares, such as {@code --jc-surface}, 349 * {@code --jc-danger-text} and {@code --jc-header-bg}. 350 * 351 * <p> 352 * These are the custom properties the chrome derives from the {@link Theme#OPEN} tokens. They are not tokens of 353 * any {@link Theme}, but a page resolves them at run time, so the FreeMarker {@code <@token scheme="dark">} 354 * alias check accepts them as targets. 355 * 356 * @return An unmodifiable set of custom-property names; never <jk>null</jk>. 357 */ 358 public static Set<String> roleAliasNames() { 359 return ROLE_ALIAS_NAMES; 360 } 361 362 /** The shipped static chrome.css bytes, read once from the classpath (shared - the static file never varies by theme). */ 363 private static volatile String staticCss; 364 365 /** 366 * Read+cache+hash+serve helper for the configured theme/chrome assets, anchored on this class so 367 * {@link ClasspathAssetCache#buildVersion()} resolves this module's own implementation version (see that 368 * class's javadoc's version-anchor section). 369 */ 370 private static final ClasspathAssetCache ASSET_CACHE = new ClasspathAssetCache(ConsoleChromeMixin.class); 371 372 private final boolean cacheAssets; 373 private final Theme theme; 374 375 /** Per-mixin-instance cache of the fully-assembled (static + theme blocks) response body, keyed by mount (see {@link #mountKey}). */ 376 private final Map<String,byte[]> cachedBodies = new ConcurrentHashMap<>(); 377 378 /** Test-only diagnostic: counts every call to {@link #buildBody(RestRequest)} (i.e. every cache miss / every call when caching is disabled). */ 379 private final AtomicInteger buildCount = new AtomicInteger(); 380 381 /** 382 * No-arg constructor, mirroring {@code FreemarkerMixin()}'s equivalent so a bean-store lookup miss can fall back 383 * to a default-configured instance without relying on {@code BeanInstantiator}'s builder-pattern detection. 384 */ 385 public ConsoleChromeMixin() { 386 this(create()); 387 } 388 389 /** 390 * Constructor. 391 * 392 * @param builder The builder. 393 */ 394 protected ConsoleChromeMixin(Builder builder) { 395 this.cacheAssets = builder.cacheAssets; 396 this.theme = builder.theme; 397 } 398 399 /** 400 * Creates a new builder. 401 * 402 * @return A new builder. 403 */ 404 public static Builder create() { 405 return new Builder(); 406 } 407 408 /** 409 * [GET /juneau-console/chrome.css] — serve the admin-console chrome stylesheet. 410 * 411 * @param req The current request, used to key the per-mount body cache. 412 * @return The chrome stylesheet as a CSS {@link HttpResource}. 413 * @throws IOException If the shipped {@code chrome.css} resource could not be read (effectively unreachable 414 * — the resource is shipped in the same jar as this class). 415 */ 416 @RestGet( 417 path={CHROME_CSS_PATH, CHROME_CSS_PATH_UNPREFIXED}, 418 summary="Admin-console chrome stylesheet", 419 description="Structural CSS for the admin-console chrome, with the selected stock theme's tokens appended.", 420 swagger=@OpSwagger(ignore=true) 421 ) 422 public HttpResource getChromeCss(RestRequest req) throws IOException { 423 var body = cacheAssets ? cachedBody(req) : buildBody(); 424 return ASSET_CACHE.wrap(body, CONTENT_TYPE, CACHE_CONTROL); 425 } 426 427 /** 428 * [GET /juneau-console/themes/{file}] — serve one of the shipped stock-theme stylesheets. 429 * 430 * <p> 431 * The {@code {file}} segment is fail-closed to the known {@link #BUILTIN_THEME_NAMES} stylesheet filenames, so 432 * this endpoint can never read an arbitrary classpath resource. Each stylesheet is a structure-free 433 * {@code html:root{}} token block (one per {@link Theme} constant); the FTL {@code <@theme name="…"/>} directive 434 * links the winning one after {@code chrome.css} so its tokens override {@code chrome.css}'s baked-in 435 * {@link Theme#OPEN} block. 436 * 437 * @param file The requested stylesheet filename (e.g. {@code juneau-theme-light-red.css}). 438 * @return The theme stylesheet as a CSS {@link HttpResource}. 439 * @throws NotFound If {@code file} is not one of the shipped stylesheet filenames. 440 */ 441 @RestGet( 442 path={THEME_CSS_DIR + "{file}", THEME_CSS_DIR_UNPREFIXED + "{file}"}, 443 summary="Admin-console stock theme stylesheet", 444 description="One of the shipped juneau-theme-<name>.css stock theme stylesheets (html:root token block), linked by the FTL <@theme> directive.", 445 swagger=@OpSwagger(ignore=true) 446 ) 447 public HttpResource getThemeCss(@Path("file") String file) { 448 if (! ALLOWED_THEME_FILES.contains(file)) 449 throw new NotFound("Unknown theme stylesheet: " + file); 450 return ASSET_CACHE.serve(THEME_CSS_RESOURCE_DIR + file, CONTENT_TYPE, CACHE_CONTROL); 451 } 452 453 /** 454 * [GET /juneau-console/juneau-console.js] — serve the console shell. 455 * 456 * @return The script. 457 */ 458 @RestGet( 459 path={CONSOLE_JS_PATH, CONSOLE_JS_PATH_UNPREFIXED}, 460 summary="Console shell script", 461 description="Renders the #juneau-page contract into header, nav, cards and footer.", 462 swagger=@OpSwagger(ignore=true) 463 ) 464 public HttpResource getConsoleJs() { 465 return ASSET_CACHE.serve(CONSOLE_JS_RESOURCE, JS_CONTENT_TYPE, CACHE_CONTROL); 466 } 467 468 /** 469 * [GET /juneau-console/juneau-badges.js] — serve the count-badge script. 470 * 471 * @return The script. 472 */ 473 @RestGet( 474 path={BADGES_JS_PATH, BADGES_JS_PATH_UNPREFIXED}, 475 summary="Console count-badge script", 476 description="Polls and renders the header.badges entries of the #juneau-page contract.", 477 swagger=@OpSwagger(ignore=true) 478 ) 479 public HttpResource getBadgesJs() { 480 return ASSET_CACHE.serve(BADGES_JS_RESOURCE, JS_CONTENT_TYPE, CACHE_CONTROL); 481 } 482 483 /** 484 * [GET /juneau-console/juneau-charts.js] — serve the chart-card runtime. 485 * 486 * @return The script. 487 */ 488 @RestGet( 489 path={CHARTS_JS_PATH, CHARTS_JS_PATH_UNPREFIXED}, 490 summary="Console chart-card runtime", 491 description="Renders type='chart' cards of the #juneau-page contract as inline SVG.", 492 swagger=@OpSwagger(ignore=true) 493 ) 494 public HttpResource getChartsJs() { 495 return ASSET_CACHE.serve(CHARTS_JS_RESOURCE, JS_CONTENT_TYPE, CACHE_CONTROL); 496 } 497 498 /** 499 * [GET /juneau-console/juneau-joblog.js] — serve the job-log card runtime. 500 * 501 * @return The script. 502 */ 503 @RestGet( 504 path={JOBLOG_JS_PATH, JOBLOG_JS_PATH_UNPREFIXED}, 505 summary="Console job-log card runtime", 506 description="Submits, streams and cancels AsyncJobsMixin jobs for type='job-log' cards.", 507 swagger=@OpSwagger(ignore=true) 508 ) 509 public HttpResource getJobLogJs() { 510 return ASSET_CACHE.serve(JOBLOG_JS_RESOURCE, JS_CONTENT_TYPE, CACHE_CONTROL); 511 } 512 513 /** 514 * [GET /juneau-console/juneau-dashboard.js] — serve the dashboard chrome runtime. 515 * 516 * @return The script. 517 */ 518 @RestGet( 519 path={DASHBOARD_JS_PATH, DASHBOARD_JS_PATH_UNPREFIXED}, 520 summary="Console dashboard chrome runtime", 521 description="The rail, grid, status header, persistence and share links of a PageSpec dashboard.", 522 swagger=@OpSwagger(ignore=true) 523 ) 524 public HttpResource getDashboardJs() { 525 return ASSET_CACHE.serve(DASHBOARD_JS_RESOURCE, JS_CONTENT_TYPE, CACHE_CONTROL); 526 } 527 528 /** 529 * [GET /juneau-console/juneau-page.schema.json] — serve the page-contract schema. 530 * 531 * @return The schema. 532 */ 533 @RestGet( 534 path={SCHEMA_PATH, SCHEMA_PATH_UNPREFIXED}, 535 summary="Console page-contract schema", 536 description="JSON Schema 2020-12 for the #juneau-page contract.", 537 swagger=@OpSwagger(ignore=true) 538 ) 539 public HttpResource getPageSchema() { 540 return ASSET_CACHE.serve(SCHEMA_RESOURCE, JSON_CONTENT_TYPE, CACHE_CONTROL); 541 } 542 543 /** 544 * Returns a real, browser-fetchable URL for a shipped theme stylesheet, resolved against the request's 545 * <b>context root</b> (not the page resource) and carrying the same {@code ?v=<buildVersion>-<hash8>} 546 * content-sensitive cache-buster {@link #chromeCssUrl(RestRequest)} uses. 547 * 548 * <p> 549 * The console document shell ({@code <@console>} / {@code <@theme>}) emits this as a {@code <link href>} to the 550 * standalone {@code /juneau-console/*} mount. Resolving with {@code context:} rather than {@code servlet:} is 551 * load-bearing: a page resource at {@code /rest/setup} reports that path as its servlet path, so 552 * {@code servlet:/juneau-console/themes/…} would become {@code /rest/setup/juneau-console/themes/…} and 404. 553 * A template-rendering consumer sits downstream of Juneau's {@code servlet:}-rewriting serializer, so it needs 554 * the URL already resolved here. 555 * 556 * @param req The current request, supplying the context path to resolve against. 557 * @param name One of the {@link #BUILTIN_THEME_NAMES}. 558 * @return The context-root-absolute theme-stylesheet URL with the version+content-hash cache-buster appended. 559 */ 560 public static String themeAssetUrl(RestRequest req, String name) { 561 var file = "juneau-theme-" + name + ".css"; 562 return consoleMountUrl(req, THEME_CSS_DIR + file, THEME_CSS_RESOURCE_DIR + file); 563 } 564 565 /** 566 * Returns a real, browser-fetchable URL for the shipped {@code chrome.css}, resolved against the request's 567 * <b>context root</b> (not the page resource) and carrying the same content-sensitive {@code ?v=…} cache-buster 568 * the theme stylesheets use. 569 * 570 * <p> 571 * The console document shell ({@code <@console>}) emits this as the first stylesheet in its head cascade. See 572 * {@link #themeAssetUrl(RestRequest, String)} for why the resolution is {@code context:} rather than 573 * {@code servlet:}. 574 * 575 * @param req The current request, supplying the context path to resolve against. 576 * @return The context-root-absolute {@code chrome.css} URL with the version+content-hash cache-buster appended. 577 */ 578 public static String chromeCssUrl(RestRequest req) { 579 return consoleMountUrl(req, CHROME_CSS_PATH, CHROME_CSS_RESOURCE); 580 } 581 582 /** 583 * Context-root-absolute, cache-busted URL of the console shell script. 584 * 585 * @param req The current request. 586 * @return The URL. 587 */ 588 public static String consoleJsUrl(RestRequest req) { 589 return consoleMountUrl(req, CONSOLE_JS_PATH, CONSOLE_JS_RESOURCE); 590 } 591 592 /** 593 * Context-root-absolute, cache-busted URL of the count-badge script. 594 * 595 * @param req The current request. 596 * @return The URL. 597 */ 598 public static String badgesJsUrl(RestRequest req) { 599 return consoleMountUrl(req, BADGES_JS_PATH, BADGES_JS_RESOURCE); 600 } 601 602 /** 603 * Context-root-absolute, cache-busted URL of a console card runtime; the resolver of the {@code charts} and 604 * {@code joblog} toolkit packs. 605 * 606 * @param req The current request. 607 * @param path {@link #CHARTS_JS_PATH}, {@link #JOBLOG_JS_PATH} or {@link #DASHBOARD_JS_PATH}. 608 * @return The URL. 609 * @throws IllegalArgumentException If {@code path} is neither. 610 */ 611 public static String consoleAssetUrl(RestRequest req, String path) { 612 if (CHARTS_JS_PATH.equals(path)) 613 return consoleMountUrl(req, CHARTS_JS_PATH, CHARTS_JS_RESOURCE); 614 if (JOBLOG_JS_PATH.equals(path)) 615 return consoleMountUrl(req, JOBLOG_JS_PATH, JOBLOG_JS_RESOURCE); 616 if (DASHBOARD_JS_PATH.equals(path)) 617 return consoleMountUrl(req, DASHBOARD_JS_PATH, DASHBOARD_JS_RESOURCE); 618 throw iaex("Unknown console asset '%s'.", path); 619 } 620 621 /** 622 * Context-root-absolute URL for an asset on the standalone {@code /juneau-console/*} mount. 623 * 624 * <p> 625 * Always the prefixed path ({@link #CHROME_CSS_PATH} / {@link #THEME_CSS_DIR}), resolved with {@code context:} 626 * so the href is {@code {contextPath}/juneau-console/…} regardless of which page resource rendered the shell. 627 */ 628 private static String consoleMountUrl(RestRequest req, String prefixedPath, String resource) { 629 return req.getUriResolver().resolve("context:" + prefixedPath) + ASSET_CACHE.cacheBuster(resource); 630 } 631 632 /** 633 * Maps one of the {@link #BUILTIN_THEME_NAMES} stock names to its {@link Theme} constant. 634 * 635 * <p> 636 * The single place the kebab-case stock name (as written on FTL {@code <@theme name="…">} / the 637 * {@code theme(String)} builder) is resolved to its shipped {@link Theme}, without a second copy of the 638 * name→theme table. 639 * 640 * @param name One of the {@link #BUILTIN_THEME_NAMES}. 641 * @return The stock {@link Theme} for {@code name}. 642 * @throws IllegalArgumentException If {@code name} is not a built-in stock-theme name. 643 */ 644 public static Theme stockTheme(String name) { 645 var t = STOCK_THEMES.get(name); 646 if (t == null) 647 throw iaex("Unknown stock theme name: '%s'. Built-in themes: %s.", name, String.join(", ", BUILTIN_THEME_NAMES)); 648 return t; 649 } 650 651 /** 652 * Returns the fully-assembled response body for the mount the request arrived under, computing (and caching) it 653 * on first call for that mount. 654 * 655 * <p> 656 * The cache is keyed by mount rather than held in a single field so one mixin instance registered under more 657 * than one container mapping (see the class javadoc's mount-styles section) builds and caches its body 658 * independently for each mount it is reached under. 659 */ 660 private byte[] cachedBody(RestRequest req) throws IOException { 661 try { 662 return cachedBodies.computeIfAbsent(mountKey(req), k -> { 663 try { 664 return buildBody(); 665 } catch (IOException e) { // HTT: staticCss() is the only throwing call and reads a resource shipped in this jar. 666 throw new UncheckedIOException(e); 667 } 668 }); 669 } catch (UncheckedIOException e) { // HTT: see above - the wrapped read cannot fail in a well-formed jar. 670 throw e.getCause(); 671 } 672 } 673 674 /** The request's mount identity: the two request properties every emitted asset URL is derived from. */ 675 private static String mountKey(RestRequest req) { 676 return req.getContextPath() + '\n' + req.getServletPath(); 677 } 678 679 /** 680 * Builds the response body: the static structural CSS, then {@link Theme#OPEN}'s block, then the selected stock 681 * theme's override block if a non-{@code open} stock theme was selected. 682 */ 683 private byte[] buildBody() throws IOException { 684 buildCount.incrementAndGet(); 685 var sb = new StringBuilder(staticCss()); 686 sb.append('\n').append(openRootBlock()); 687 var active = theme == null ? Theme.OPEN : theme; 688 if (! active.getName().equals(Theme.OPEN.getName())) 689 sb.append('\n').append(overrideBlock(active)); 690 return sb.toString().getBytes(StandardCharsets.UTF_8); 691 } 692 693 /** 694 * Test-only diagnostic: the number of times this instance has (re)assembled its response body. 695 * 696 * @return The build count. 697 */ 698 int debugBuildCount() { return buildCount.get(); } 699 700 /** 701 * Renders a theme's override block: one {@code html:root{...}} carrying the theme's leaf tokens, then its 702 * alias tokens, each in declaration order. 703 * 704 * <p> 705 * The two halves are escaped differently. A leaf is a validated literal and goes through 706 * {@link CssValueEscaper}: a grammar-accepted value can still carry a character such as {@code ;} that would end 707 * the declaration early. An alias is a single anchored {@code var(--jc-name)} reference 708 * ({@link Theme.Builder#alias(String, String)}), in which nothing dangerous is representable, so it is emitted 709 * verbatim. That asymmetry is the reason {@link Theme#getAliases()} stays a map. 710 * 711 * <p> 712 * The block uses the {@code html} type prefix. Two {@code :root} blocks in separately linked stylesheets tie at 713 * specificity {@code (0,0,1,0)} and are resolved by {@code <link>} order, which this framework neither sets 714 * nor observes. {@code html:root} scores {@code (0,0,1,1)}, so a theme override wins in either order. 715 * 716 * <p> 717 * A theme with no leaves and no aliases renders as the empty string. Each declared name is re-checked against 718 * the reserved {@code --jc-chrome-*} namespace (defense in depth; a {@link Theme} built by its builder can't 719 * carry one). This method is public so the FreeMarker {@code <@theme>} directive renders its inline block with 720 * the same code that serves {@code chrome.css}. 721 * 722 * <h5 class='section'>Example:</h5> 723 * <p class='bjava'> 724 * Theme <jv>theme</jv> = Theme.<jsm>create</jsm>(<js>"corporate"</js>) 725 * .token(<js>"--jc-accent"</js>, <js>"#b45309"</js>) 726 * .alias(<js>"--jc-tab-bar-bg"</js>, <js>"var(--jc-card-bg)"</js>) 727 * .build(); 728 * String <jv>css</jv> = ConsoleChromeMixin.<jsm>overrideBlock</jsm>(<jv>theme</jv>); 729 * <jc>// html:root{--jc-accent:#b45309;--jc-tab-bar-bg:var(--jc-card-bg);}</jc> 730 * </p> 731 * 732 * @param theme The theme. Must not be <jk>null</jk>. 733 * @return The override block, or an empty string if the theme declares nothing. 734 */ 735 public static String overrideBlock(Theme theme) { 736 if (theme.getTokens().isEmpty() && theme.getAliases().isEmpty()) 737 return ""; 738 var sb = new StringBuilder("html:root{"); 739 for (var e : theme.getTokens().entrySet()) { 740 Theme.rejectReservedChromeDeclaration(e.getKey(), "theme '" + theme.getName() + "' leaf token"); 741 sb.append(e.getKey()).append(':').append(CssValueEscaper.escape(e.getValue())).append(';'); 742 } 743 for (var e : theme.getAliases().entrySet()) { 744 Theme.rejectReservedChromeDeclaration(e.getKey(), "theme '" + theme.getName() + "' alias"); 745 sb.append(e.getKey()).append(':').append(e.getValue()).append(';'); 746 } 747 return sb.append('}').toString(); 748 } 749 750 /** 751 * The two head insertion points for a colour-scheme-aware page. 752 * 753 * <p> 754 * They are separate because they land in different places: {@link #htmlAttributes()} goes inside the 755 * {@code <html>} start tag, and {@link #head()} goes immediately after {@code <head>}, ahead of every stylesheet. 756 * 757 * @param htmlAttributes The attributes for the {@code <html>} start tag, space-separated with no leading space, 758 * or an empty string when the page is neither pinned nor toggleable. 759 * @param head The pre-paint script element followed by the dark-layer stylesheet link, newline-terminated. 760 */ 761 public record SchemeHead(String htmlAttributes, String head) {} 762 763 /** 764 * Returns the colour-scheme head pieces for a page that does not render through the FreeMarker console directive. 765 * 766 * <p> 767 * A pinned page ({@code light} or {@code dark}) gets {@code data-jc-scheme} and {@code data-jc-scheme-pin} set to 768 * the pin, so it paints in that scheme even with scripts disabled. A toggleable page gets 769 * {@code data-jc-scheme-toggle}, which lets the pre-paint script honour the visitor's stored choice. The head 770 * piece is {@link #SCHEME_SCRIPT} (stamped with the request's CSP nonce when one is set) followed by a link to the 771 * shipped dark layer. 772 * 773 * <h5 class='section'>Example:</h5> 774 * <p class='bjava'> 775 * SchemeHead <jv>h</jv> = ConsoleChromeMixin.<jsm>schemeHead</jsm>(<jv>req</jv>, <js>"auto"</js>, <jk>true</jk>); 776 * <jk>var</jk> <jv>attrs</jv> = <jv>h</jv>.htmlAttributes().isEmpty() ? <js>""</js> : <js>" "</js> + <jv>h</jv>.htmlAttributes(); 777 * <jv>out</jv>.write(<js>"<html lang=\"en\""</js> + <jv>attrs</jv> + <js>"><head>"</js> + <jv>h</jv>.head()); 778 * </p> 779 * 780 * <p> 781 * The head piece carries only the scheme script and the dark layer. It does not replace the page's own links to 782 * the chrome stylesheet and a theme palette, which the caller still emits. 783 * 784 * @param req The current request, supplying the context path and any CSP nonce. 785 * @param pin One of the {@link #SCHEMES}. 786 * @param toggle Whether the visitor may override the scheme with a stored choice. 787 * @return The head pieces. 788 * @throws IllegalArgumentException If {@code pin} is <jk>null</jk> or not one of the {@link #SCHEMES}. 789 */ 790 public static SchemeHead schemeHead(RestRequest req, String pin, boolean toggle) { 791 var attrs = schemeHtmlAttributes(pin, toggle); 792 var nonce = req.getAttribute(RestRequest.CSP_NONCE_ATTR).as(String.class).orElse(null); 793 var head = schemeScriptElement(nonce) + "\n<link rel=\"stylesheet\" href=\"" + attrEscape(themeAssetUrl(req, "dark")) + "\">\n"; 794 return new SchemeHead(attrs, head); 795 } 796 797 /** 798 * Returns the {@code <html>} start-tag attributes for a scheme pin and toggle flag. 799 * 800 * <p> 801 * The FreeMarker console directive and {@link #schemeHead(RestRequest, String, boolean)} share this so the two 802 * shells stamp identical attributes. 803 * 804 * @param pin One of the {@link #SCHEMES}. 805 * @param toggle Whether the visitor may override the scheme with a stored choice. 806 * @return The attributes, space-separated with no leading space, or an empty string. 807 * @throws IllegalArgumentException If {@code pin} is <jk>null</jk> or not one of the {@link #SCHEMES}. 808 */ 809 public static String schemeHtmlAttributes(String pin, boolean toggle) { 810 if (pin == null || ! SCHEMES.contains(pin)) 811 throw iaex("Unknown scheme '%s'. Schemes: %s.", pin, String.join(", ", SCHEMES)); 812 var sb = new StringBuilder(); 813 if (! "auto".equals(pin)) 814 sb.append("data-jc-scheme=\"").append(pin).append("\" data-jc-scheme-pin=\"").append(pin).append('"'); 815 if (toggle) 816 sb.append(sb.isEmpty() ? "" : " ").append("data-jc-scheme-toggle"); 817 return sb.toString(); 818 } 819 820 /** 821 * Returns the {@link #SCHEME_SCRIPT} element, carrying {@code nonce} as an attribute when it is not <jk>null</jk>. 822 * 823 * @param nonce The request's CSP nonce, or <jk>null</jk>. 824 * @return The {@code <script>} element, with no trailing newline. 825 */ 826 public static String schemeScriptElement(String nonce) { 827 return "<script" + nonceAttr(nonce) + ">" + SCHEME_SCRIPT + "</script>"; 828 } 829 830 /** 831 * Returns {@code nonce="…"} (with a leading space, attribute-escaped) or an empty string for <jk>null</jk>. 832 * 833 * @param nonce The request's CSP nonce, or <jk>null</jk>. 834 * @return The attribute text. 835 */ 836 public static String nonceAttr(String nonce) { 837 return nonce == null ? "" : " nonce=\"" + attrEscape(nonce) + "\""; 838 } 839 840 /** 841 * Renders the dark-scheme override rule for app-declared dark tokens. 842 * 843 * <p> 844 * The rule is {@code @media not print{html:root[data-jc-scheme="dark"]{…}}}, so it wins over the light override 845 * block only while the page is dark and never reaches print. Leaves come first, then aliases, each in declaration 846 * order. A leaf must be a literal: a dark leaf that is itself a {@code var()} reference is rejected, because it 847 * would resolve against the light palette. A live link belongs in an alias. Names and values get the same checks 848 * as {@link Theme.Builder}: the name shape, the reserved {@code --jc-chrome-*} namespace, the value grammar, and 849 * one channel per name. 850 * 851 * <h5 class='section'>Example:</h5> 852 * <p class='bjava'> 853 * String <jv>css</jv> = ConsoleChromeMixin.<jsm>schemeOverrideBlock</jsm>( 854 * Map.<jsm>of</jsm>(<js>"--jc-link"</js>, <js>"#6cb4ff"</js>), Map.<jsm>of</jsm>(<js>"--jc-focus"</js>, <js>"var(--jc-accent)"</js>)); 855 * <jc>// @media not print{html:root[data-jc-scheme="dark"]{--jc-link:#6cb4ff;--jc-focus:var(--jc-accent);}}</jc> 856 * </p> 857 * 858 * @param tokens The dark leaf tokens by name. Must not be <jk>null</jk>. 859 * @param aliases The dark aliases by name, each a {@code var(--jc-name)} reference. Must not be <jk>null</jk>. 860 * @return The rule, or an empty string if both maps are empty. 861 * @throws IllegalArgumentException If a name or value fails validation. 862 */ 863 public static String schemeOverrideBlock(Map<String,String> tokens, Map<String,String> aliases) { 864 if (tokens.isEmpty() && aliases.isEmpty()) 865 return ""; 866 var b = Theme.create("dark"); 867 for (var e : tokens.entrySet()) { 868 if (CssValueGrammar.normalize(e.getValue()).toLowerCase(Locale.ROOT).contains("var(")) 869 throw iaex("Dark token '%s' is '%s', but dark values must be literals. Declare a live reference as an alias.", e.getKey(), e.getValue()); 870 b.token(e.getKey(), e.getValue()); 871 } 872 for (var e : aliases.entrySet()) 873 b.alias(e.getKey(), e.getValue()); 874 var t = b.build(); 875 var sb = new StringBuilder("@media not print{html:root[data-jc-scheme=\"dark\"]{"); 876 for (var e : t.getTokens().entrySet()) 877 sb.append(e.getKey()).append(':').append(CssValueEscaper.escape(e.getValue())).append(';'); 878 for (var e : t.getAliases().entrySet()) 879 sb.append(e.getKey()).append(':').append(e.getValue()).append(';'); 880 return sb.append("}}").toString(); 881 } 882 883 /** Escapes text for a double-quoted HTML attribute value. */ 884 private static String attrEscape(String s) { 885 return s.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """); 886 } 887 888 /** Returns the CSP {@code 'sha256-…'} source expression for {@code text}'s UTF-8 bytes. */ 889 private static String sha256Source(String text) { 890 try { 891 var digest = java.security.MessageDigest.getInstance("SHA-256").digest(text.getBytes(StandardCharsets.UTF_8)); 892 return "'sha256-" + Base64.getEncoder().encodeToString(digest) + "'"; 893 } catch (java.security.NoSuchAlgorithmException e) { // HTT: every Java platform must provide SHA-256. 894 throw new IllegalStateException(e); 895 } 896 } 897 898 /** 899 * Renders {@link Theme#OPEN}'s {@code html:root{}} block with the framework-authored role-token alias 900 * derivations ({@link #OPEN_ROLE_ALIASES}) appended inside the same block, immediately after the leaf tokens. 901 * The aliases are appended as trusted framework literal text (not routed through {@code CssValueEscaper}, 902 * which is for consumer-supplied values); folding them into {@code Theme.OPEN}'s block keeps the served 903 * response at exactly one {@code html:root{}} block for the default theme while still placing every derived 904 * default before any active-theme override block. See {@link #overrideBlock(Theme)} for why the selector carries 905 * the {@code html} type prefix. 906 */ 907 private static String openRootBlock() { 908 var sb = new StringBuilder(); 909 sb.append("html:root{"); 910 for (var e : Theme.OPEN.getTokens().entrySet()) 911 sb.append(e.getKey()).append(':').append(CssValueEscaper.escape(e.getValue())).append(';'); 912 sb.append(OPEN_ROLE_ALIASES); 913 sb.append('}'); 914 return sb.toString(); 915 } 916 917 /** Returns the shipped static chrome.css text, reading (and caching, process-wide - the static file never varies) it from the classpath on first call. */ 918 private static String staticCss() throws IOException { 919 var s = staticCss; 920 if (s == null) { 921 synchronized (ConsoleChromeMixin.class) { 922 s = staticCss; 923 if (s == null) { // HTT: the "already set" branch is only reachable under a lock-acquisition race - unhittable single-threaded. 924 try (var in = ConsoleChromeMixin.class.getResourceAsStream(CHROME_CSS_RESOURCE)) { 925 if (in == null) 926 throw new IOException("Classpath resource not found: " + CHROME_CSS_RESOURCE); 927 s = IoUtils.read(in); 928 } 929 staticCss = s; 930 } 931 } 932 } 933 return s; 934 } 935 936 /** 937 * Builder for {@link ConsoleChromeMixin}. 938 */ 939 public static class Builder { 940 boolean cacheAssets = true; 941 Theme theme; 942 943 /** 944 * Whether to cache the assembled response body after the first request (default <jk>true</jk>). 945 * 946 * @param value The new value. 947 * @return This object. 948 */ 949 public Builder cacheAssets(boolean value) { 950 this.cacheAssets = value; 951 return this; 952 } 953 954 /** 955 * Selects one of the shipped stock palettes by name (the same names FTL {@code <@theme name="…">} accepts). 956 * 957 * <p> 958 * A custom palette is authored in FTL with {@code <@theme>}/{@code <@token>}. 959 * 960 * @param stockName One of the {@link ConsoleChromeMixin#BUILTIN_THEME_NAMES} 961 * ({@code open}, {@code light-red}, {@code light-brown}, {@code red}, {@code gray}). 962 * @return This object. 963 * @throws IllegalArgumentException If {@code stockName} is not a built-in stock-theme name. 964 */ 965 public Builder theme(String stockName) { 966 this.theme = stockTheme(stockName); 967 return this; 968 } 969 970 /** 971 * Builds the mixin. 972 * 973 * @return A new {@link ConsoleChromeMixin}. 974 */ 975 public ConsoleChromeMixin build() { 976 return new ConsoleChromeMixin(this); 977 } 978 } 979}