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.*;
020import static org.apache.juneau.console.freemarker.ToolkitPack.Kind.*;
021
022import java.util.*;
023import java.util.function.UnaryOperator;
024import java.util.logging.*;
025
026import org.apache.juneau.rest.server.*;
027import org.apache.juneau.console.*;
028import org.apache.juneau.rest.server.staticfile.*;
029import org.apache.juneau.terminal.region.*;
030import org.apache.juneau.console.views.*;
031import org.apache.juneau.console.widgets.*;
032
033/**
034 * Registry of named toolkit packs for the {@code <@page toolkit=...>} directive and for the packs cards require.
035 *
036 * <p>
037 * Built-in packs:
038 * <ul>
039 *    <li>{@code "jquery"}, {@code "datatables"} and {@code "datatables-buttons"} ({@link ToolkitPack.Kind#VENDOR}) &mdash;
040 *       WebJars, resolved through {@link #WEBJAR_RESOLVER} to the host's {@link WebJarsMixin} mount.
041 *       {@code "datatables"} depends on {@code "jquery"}; {@code "datatables-buttons"} on {@code "datatables"}.
042 *    <li>{@code "datatables-glue"} ({@link ToolkitPack.Kind#VENDOR}) &mdash; the first-party glue
043 *       ({@link ViewsMixin#DATATABLES_JS_PATH}, served by {@code DataTablesMixin}); depends on {@code "datatables"}.
044 *       Every {@code <@card type="datatables">} requires it.
045 *    <li>{@code "views"} ({@link ToolkitPack.Kind#RUNTIME}) &mdash; the views runtime.  JS order is a contract: bus (first), renders,
046 *       icons, search, pagestate, urlstate, ribbon, views, config, regions, terminal-view, helpers.
047 *       {@code juneau-urlstate.js} must precede {@code juneau-views.js} / {@code juneau-config.js}.
048 *    <li>{@code "calendar"} ({@link ToolkitPack.Kind#RUNTIME}) &mdash; the {@code juneau-calendar.*} runtime from
049 *       {@code juneau-console-widgets}, resolved through {@link #WIDGETS_RESOLVER}.
050 *    <li>{@code "xterm"} ({@link ToolkitPack.Kind#VENDOR}) &mdash; the xterm.js WebJar (library and stylesheet), resolved
051 *       through {@link #WEBJAR_RESOLVER}.
052 *    <li>{@code "terminal"} ({@link ToolkitPack.Kind#RUNTIME}) &mdash; the {@code juneau-terminal.*} runtime from
053 *       {@code juneau-terminal-region}, resolved through {@link TerminalMixin#terminalAssetUrl(RestRequest, String)};
054 *       depends on {@code "views"} and {@code "xterm"}.  Every {@code <@card type="terminal">} requires it, so a page
055 *       with a terminal card needs no {@code toolkit=}.
056 *    <li>{@code "charts"} ({@link ToolkitPack.Kind#RUNTIME}) &mdash; {@code juneau-charts.js} from
057 *       {@code juneau-console}, resolved through {@link ConsoleChromeMixin#consoleAssetUrl(RestRequest, String)};
058 *       depends on {@code "echarts"} and {@code "data"}.  Every {@code <@card type="chart">} requires it.
059 *    <li>{@code "joblog"} ({@link ToolkitPack.Kind#RUNTIME}) &mdash; {@code juneau-joblog.js} from
060 *       {@code juneau-console}; no dependencies.  Every {@code <@card type="job-log">} requires it.
061 *    <li>{@code "echarts"} ({@link ToolkitPack.Kind#VENDOR}) &mdash; the ECharts WebJar ({@link #ECHARTS_JS_ASSET}),
062 *       optional on the classpath: a missing jar is E-70 at startup when the app opts in
063 *       ({@code ConsoleFreemarkerMixin.Builder.requirePacks}), else at the first chart page.
064 *    <li>{@code "echarts-gl"} ({@link ToolkitPack.Kind#VENDOR}) &mdash; the echarts-gl WebJar; depends on {@code "echarts"};
065 *       a page opts in with {@code toolkit("echarts-gl")}.
066 *    <li>{@code "data"} ({@link ToolkitPack.Kind#RUNTIME}) &mdash; {@code juneau-data.js} ({@code JuneauViews.data}); depends on
067 *       {@code "views"}.
068 * </ul>
069 *
070 * <p>
071 * {@link #resolve(List, RestRequest, List, String)} expands the requested packs depth-first over
072 * {@link ToolkitPack#dependsOn()} (each pack after its dependencies, siblings in request order, each pack once), skips
073 * {@linkplain #provide(Collection) provided} packs (their dependencies still load), skips an asset whose file name the
074 * page already loads through {@code init=} or {@code css=} (with a WARN), and splits the result by kind.
075 * {@link #validate()} checks the static graph: unknown {@code dependsOn} names, cycles, and a VENDOR pack depending on
076 * a RUNTIME pack.
077 *
078 * @since 10.0.0
079 */
080@SuppressWarnings({
081   "java:S1192" // Repeated literals are contract wire keys; named constants would obscure the contract shape.
082})
083public final class ToolkitPackRegistry {
084
085   private static final Logger LOG = Logger.getLogger(ToolkitPackRegistry.class.getName());
086
087   /** The built-in views runtime pack. */
088   public static final String PACK_VIEWS = "views";
089
090   /**
091    * The built-in {@code "calendar"} pack: the already-shipping {@code juneau-calendar.*} runtime from
092    * {@code juneau-console-widgets}.  Never inferred from {@code type="calendar"}; a calendar page lists it
093    * explicitly (e.g. {@code toolkit="views,calendar"}).
094    */
095   public static final String PACK_CALENDAR = "calendar";
096
097   /** The built-in jQuery WebJar pack. */
098   public static final String PACK_JQUERY = "jquery";
099
100   /** The built-in DataTables WebJar pack (library plus default styling CSS). */
101   public static final String PACK_DATATABLES = "datatables";
102
103   /** The built-in pack holding the first-party DataTables glue. */
104   public static final String PACK_DATATABLES_GLUE = "datatables-glue";
105
106   /** The built-in DataTables Buttons WebJar pack (copy and CSV export, no JSZip or pdfmake). */
107   public static final String PACK_DATATABLES_BUTTONS = "datatables-buttons";
108
109   /** The built-in xterm.js WebJar pack (library plus stylesheet). */
110   public static final String PACK_XTERM = "xterm";
111
112   /** The built-in terminal runtime pack; depends on {@code "views"} and {@code "xterm"}. */
113   public static final String PACK_TERMINAL = "terminal";
114
115   /** The built-in chart-card runtime pack ({@code juneau-charts.js}); depends on {@code "echarts"} and {@code "data"}. */
116   public static final String PACK_CHARTS = "charts";
117
118   /** The built-in job-log card runtime pack ({@code juneau-joblog.js}); no dependencies. */
119   public static final String PACK_JOBLOG = "joblog";
120
121   /** Dashboard chrome ({@code PageSpec.dashboard}); added by the builder, never needed in {@code toolkit(...)}. */
122   public static final String PACK_DASHBOARD = "dashboard";
123
124   /** The built-in ECharts WebJar pack ({@code org.webjars.npm:echarts}, optional on the classpath). */
125   public static final String PACK_ECHARTS = "echarts";
126
127   /** The built-in echarts-gl WebJar pack; depends on {@code "echarts"}; never required by a card type. */
128   public static final String PACK_ECHARTS_GL = "echarts-gl";
129
130   /** The built-in DataRegistry runtime pack ({@code juneau-data.js}); depends on {@code "views"}. */
131   public static final String PACK_DATA = "data";
132
133   private static final String NPM = "org.webjars.npm";
134
135   /** The ECharts library asset id, for {@link #WEBJAR_RESOLVER}. */
136   public static final String ECHARTS_JS_ASSET = WebJarResolver.asset(NPM, "echarts", "echarts/{version}/dist/echarts.min.js");
137
138   /** The echarts-gl library asset id (path unverified against the WebJar; no module depends on it). */
139   public static final String ECHARTS_GL_JS_ASSET = WebJarResolver.asset(NPM, "echarts-gl", "echarts-gl/{version}/dist/echarts-gl.min.js");
140
141   static final String E70 = "chart cards need the ECharts WebJar org.webjars.npm:echarts, which is not on the classpath; add it to the application's pom (version property 'webjars-echarts.version')";
142   static final String E70_PACK = "toolkit pack '%s' needs the WebJar %s, which is not on the classpath; add it to the application's pom (version property '%s')";
143
144   // groupId:artifactId -> the version property the message names.
145   // echarts is not listed: it has its own message (E70) in webJarMissing.
146   private static final Map<String,String> WEBJAR_PROPERTIES = Map.of(
147      NPM + ":echarts-gl", "webjars-echarts-gl.version");
148
149   /** Resolves an asset path to an absolute, cache-busted URL against the in-flight request. */
150   @FunctionalInterface
151   public interface AssetUrlResolver {
152      /**
153       * @param req The in-flight request.
154       * @param path The asset path.
155       * @return The resolved, cache-busted URL.
156       */
157      String resolve(RestRequest req, String path);
158   }
159
160   /** The default resolver: {@link ViewsMixin#viewAssetUrl(RestRequest, String)}. */
161   public static final AssetUrlResolver VIEWS_RESOLVER = ViewsMixin::viewAssetUrl;
162
163   /** The {@code "calendar"} pack resolver: {@link WidgetsMixin#widgetAssetUrl(RestRequest, String)}. */
164   public static final AssetUrlResolver WIDGETS_RESOLVER = WidgetsMixin::widgetAssetUrl;
165
166   /** The WebJar resolver: paths are {@link WebJarResolver#asset(String, String, String)} ids. */
167   public static final AssetUrlResolver WEBJAR_RESOLVER = WebJarResolver::resolve;
168
169   /**
170    * Resolved absolute URLs, split by pack kind.
171    *
172    * @param vendorCss VENDOR CSS, emitted before the page {@code css=}.
173    * @param vendorJs VENDOR JS, emitted before the runtime JS.
174    * @param runtimeCss RUNTIME CSS, emitted after the page {@code css=}.
175    * @param runtimeJs RUNTIME JS, emitted before the {@code after-toolkit} slot.
176    */
177   public record Resolved(List<String> vendorCss, List<String> vendorJs, List<String> runtimeCss, List<String> runtimeJs) {
178      /** Nothing to load. */
179      public static final Resolved EMPTY = new Resolved(List.of(), List.of(), List.of(), List.of());
180   }
181
182   private final Map<String,ToolkitPack> packs = new LinkedHashMap<>();
183   private final Set<String> provided = new LinkedHashSet<>();
184
185   /** Constructs the registry with the built-in packs installed. */
186   public ToolkitPackRegistry() {
187      register(ToolkitPack.create(PACK_JQUERY).kind(VENDOR).resolver(WEBJAR_RESOLVER)
188         .js(WebJarResolver.asset("org.webjars", "jquery", "jquery/{version}/jquery.min.js"))
189         .build());
190      register(ToolkitPack.create(PACK_DATATABLES).kind(VENDOR).resolver(WEBJAR_RESOLVER)
191         .css(WebJarResolver.asset(NPM, "datatables.net-dt", "datatables.net-dt/{version}/css/dataTables.dataTables.min.css"))
192         .js(WebJarResolver.asset(NPM, "datatables.net", "datatables.net/{version}/js/dataTables.min.js"))
193         .dependsOn(PACK_JQUERY)
194         .build());
195      register(ToolkitPack.create(PACK_DATATABLES_GLUE).kind(VENDOR).resolver(VIEWS_RESOLVER)
196         .js(ViewsMixin.DATATABLES_JS_PATH)
197         .dependsOn(PACK_DATATABLES)
198         .build());
199      register(ToolkitPack.create(PACK_DATATABLES_BUTTONS).kind(VENDOR).resolver(WEBJAR_RESOLVER)
200         .css(WebJarResolver.asset(NPM, "datatables.net-buttons-dt", "datatables.net-buttons-dt/{version}/css/buttons.dataTables.min.css"))
201         .js(
202            WebJarResolver.asset(NPM, "datatables.net-buttons", "datatables.net-buttons/{version}/js/dataTables.buttons.min.js"),
203            WebJarResolver.asset(NPM, "datatables.net-buttons", "datatables.net-buttons/{version}/js/buttons.html5.min.js"))
204         .dependsOn(PACK_DATATABLES)
205         .build());
206      register(ToolkitPack.create(PACK_VIEWS).kind(RUNTIME).resolver(VIEWS_RESOLVER)
207         .css(ViewsMixin.VIEWS_CSS_PATH, ViewsMixin.CONFIG_CSS_PATH)
208         .js(
209            // Dependency-free and read by views, regions and the console shell: it must load first (E-JS-46).
210            ViewsMixin.BUS_JS_PATH,
211            ViewsMixin.RENDERS_JS_PATH,
212            ViewsMixin.ICONS_JS_PATH,
213            ViewsMixin.SEARCH_JS_PATH,
214            ViewsMixin.PAGESTATE_JS_PATH,
215            ViewsMixin.URLSTATE_JS_PATH,
216            ViewsMixin.RIBBON_JS_PATH,
217            ViewsMixin.VIEWS_JS_PATH,
218            ViewsMixin.CONFIG_JS_PATH,
219            ViewsMixin.REGIONS_JS_PATH,
220            ViewsMixin.RUN_VIEW_JS_PATH,
221            ViewsMixin.HELPERS_JS_PATH)
222         .build());
223      // The "calendar" pack ships from juneau-console-widgets and resolves through that mixin's own
224      // cache-busting helper, not VIEWS_RESOLVER.  A page that wants it lists toolkit="views,calendar" so
225      // juneau-views.js still loads before juneau-calendar.js (shared layer stack).
226      register(ToolkitPack.create(PACK_CALENDAR).kind(RUNTIME).resolver(WIDGETS_RESOLVER)
227         .css(WidgetsMixin.CALENDAR_CSS_PATH)
228         .js(WidgetsMixin.CALENDAR_JS_PATH)
229         .build());
230      register(ToolkitPack.create(PACK_XTERM).kind(VENDOR).resolver(WEBJAR_RESOLVER)
231         .css(TerminalMixin.XTERM_CSS_ASSET)
232         .js(TerminalMixin.XTERM_JS_ASSET)
233         .build());
234      register(ToolkitPack.create(PACK_TERMINAL).kind(RUNTIME).resolver(TerminalMixin::terminalAssetUrl)
235         .css(TerminalMixin.CSS_PATH)
236         .js(TerminalMixin.JS_PATH)
237         .dependsOn(PACK_VIEWS, PACK_XTERM)
238         .build());
239      register(ToolkitPack.create(PACK_ECHARTS).kind(VENDOR).resolver(WEBJAR_RESOLVER)
240         .js(ECHARTS_JS_ASSET)
241         .build());
242      register(ToolkitPack.create(PACK_ECHARTS_GL).kind(VENDOR).resolver(WEBJAR_RESOLVER)
243         .js(ECHARTS_GL_JS_ASSET)
244         .dependsOn(PACK_ECHARTS)
245         .build());
246      register(ToolkitPack.create(PACK_DATA).kind(RUNTIME).resolver(VIEWS_RESOLVER)
247         .js(ViewsMixin.DATA_JS_PATH)
248         .dependsOn(PACK_VIEWS)
249         .build());
250      register(ToolkitPack.create(PACK_CHARTS).kind(RUNTIME).resolver(ConsoleChromeMixin::consoleAssetUrl)
251         .js(ConsoleChromeMixin.CHARTS_JS_PATH)
252         .dependsOn(PACK_ECHARTS, PACK_DATA)
253         .build());
254      register(ToolkitPack.create(PACK_JOBLOG).kind(RUNTIME).resolver(ConsoleChromeMixin::consoleAssetUrl)
255         .js(ConsoleChromeMixin.JOBLOG_JS_PATH)
256         .build());
257      register(ToolkitPack.create(PACK_DASHBOARD).kind(RUNTIME).resolver(ConsoleChromeMixin::consoleAssetUrl)
258         .js(ConsoleChromeMixin.DASHBOARD_JS_PATH)
259         .dependsOn(PACK_VIEWS, PACK_DATA)
260         .build());
261   }
262
263   /**
264    * Registers a pack, replacing any pack with the same name (all of its fields, including {@code dependsOn}).
265    *
266    * @param pack The pack.
267    */
268   public synchronized void register(ToolkitPack pack) {
269      packs.put(pack.name(), pack);
270   }
271
272   /**
273    * Marks packs as already loaded by the app: they contribute no URLs, but their dependencies still load.
274    *
275    * @param names The pack names.
276    * @return This object.
277    */
278   public synchronized ToolkitPackRegistry provide(Collection<String> names) {
279      provided.addAll(names);
280      return this;
281   }
282
283   /**
284    * @param name A pack name.
285    * @return <jk>true</jk> if a pack with that name is registered.
286    */
287   public synchronized boolean contains(String name) {
288      return packs.containsKey(name);
289   }
290
291   /**
292    * Checks the static graph: every {@code dependsOn} and provided name is registered, there is no cycle, and no
293    * VENDOR pack depends on a RUNTIME pack.
294    *
295    * @return This object.
296    * @throws IllegalArgumentException On the first problem found.
297    */
298   public synchronized ToolkitPackRegistry validate() {
299      for (var pack : packs.values()) {
300         for (var dep : pack.dependsOn()) {
301            var target = packs.get(dep);
302            if (n(target))
303               throw iaex("Unknown toolkit pack '%s' (dependsOn of pack '%s').", dep, pack.name());
304            if (pack.kind() == VENDOR && target.kind() == RUNTIME)
305               throw iaex("VENDOR toolkit pack '%s' cannot depend on RUNTIME pack '%s'.", pack.name(), dep);
306         }
307      }
308      for (var name : provided)
309         if (! packs.containsKey(name))
310            throw iaex("Unknown toolkit pack '%s' (providedPacks).", name);
311      expand(packs.keySet(), "registered");
312      return this;
313   }
314
315   /**
316    * Checks that every name is a registered pack.
317    *
318    * @param names The pack names.
319    * @param source Where the names came from, for the message (e.g. {@code <@page toolkit=>}).
320    * @throws IllegalArgumentException If a name is unknown.
321    */
322   public synchronized void requireKnown(Collection<String> names, String source) {
323      for (var name : names)
324         if (! packs.containsKey(name))
325            throw iaex("Unknown toolkit pack '%s' (%s).", name, source);
326   }
327
328   /**
329    * @param root A registered pack name.
330    * @param target A pack name.
331    * @return <jk>true</jk> if {@code target} is {@code root} or one of its transitive {@code dependsOn}.
332    */
333   public synchronized boolean reaches(String root, String target) {
334      return expand(List.of(root), "reaches").stream().anyMatch(p -> p.name().equals(target));
335   }
336
337   /**
338    * Probes, at startup, every WebJar the given packs reach: each non-provided VENDOR pack resolved through
339    * {@link #WEBJAR_RESOLVER}.
340    *
341    * @param roots Root pack names; empty probes nothing.
342    * @return This object.
343    * @throws IllegalStateException E-70 when a WebJar or its file is not on the classpath.
344    */
345   public ToolkitPackRegistry checkWebJars(Collection<String> roots) {
346      return checkWebJars(roots, WebJarResolver::resolvePath);
347   }
348
349   // Package-private: the probe is injectable because the echarts jar is on every test classpath.
350   synchronized ToolkitPackRegistry checkWebJars(Collection<String> roots, UnaryOperator<String> probe) {
351      for (var pack : expand(new LinkedHashSet<>(roots), "requirePacks")) {
352         if (provided.contains(pack.name()) || pack.kind() != VENDOR || pack.resolver() != WEBJAR_RESOLVER)
353            continue;
354         for (var asset : concat(pack.cssPaths(), pack.jsPaths())) {
355            try {
356               probe.apply(asset);
357            } catch (IllegalStateException e) {
358               var x = webJarMissing(pack, asset, e);
359               throw x != null ? x : new IllegalStateException("Pack '" + pack.name() + "' " + e.getMessage(), e);
360            }
361         }
362      }
363      return this;
364   }
365
366   private static List<String> concat(List<String> a, List<String> b) {
367      var out = new ArrayList<String>(a);
368      out.addAll(b);
369      return out;
370   }
371
372   // Distinguishes "jar absent" from "jar present, file missing" (the resolver's own message says which).
373   private static String causeSuffix(RuntimeException cause) {
374      return nn(cause.getMessage()) ? " [" + cause.getMessage() + "]" : "";
375   }
376
377   // E-70 for a WebJar the message table knows; null when the asset is not a known WebJar.
378   private static IllegalStateException webJarMissing(ToolkitPack pack, String asset, RuntimeException cause) {
379      var parts = asset.split(":", 3);
380      var coords = parts.length == 3 ? parts[0] + ":" + parts[1] : asset;
381      if (eq(coords, NPM + ":echarts"))
382         return new IllegalStateException("E-70: " + E70 + causeSuffix(cause), cause);
383      var prop = WEBJAR_PROPERTIES.get(coords);
384      if (prop == null)
385         return null;
386      return new IllegalStateException("E-70: " + f(E70_PACK, pack.name(), coords, prop) + causeSuffix(cause), cause);
387   }
388
389   /**
390    * Resolves the root packs and their dependencies to absolute URLs, split by kind.
391    *
392    * @param names The root pack names, in order.  Empty or <jk>null</jk> = nothing (no request needed).
393    * @param req The in-flight request.
394    * @param pageUrls The page's own {@code init=} and {@code css=} URLs; a pack asset with the same file name is
395    *    skipped with a WARN.
396    * @param template The page template name, for the WARN.
397    * @return The resolved URLs.
398    * @throws IllegalArgumentException On an unknown pack or a cycle.
399    * @throws IllegalStateException If the request is missing or a resolver fails (e.g. a WebJar isn't on the classpath).
400    */
401   public Resolved resolve(List<String> names, RestRequest req, List<String> pageUrls, String template) {
402      if (n(names) || names.isEmpty())
403         return Resolved.EMPTY;
404      if (n(req))
405         throw isex("<@page toolkit> needs FreemarkerRenderScope.request() (renderer wrap).");
406      List<ToolkitPack> order;
407      Set<String> skip;
408      synchronized (this) {
409         order = expand(new LinkedHashSet<>(names), "requested");
410         skip = Set.copyOf(provided);
411      }
412      var pageFiles = new LinkedHashMap<String,String>();
413      for (var url : pageUrls)
414         pageFiles.putIfAbsent(fileName(url), url);
415      var vendorCss = new ArrayList<String>();
416      var vendorJs = new ArrayList<String>();
417      var runtimeCss = new ArrayList<String>();
418      var runtimeJs = new ArrayList<String>();
419      for (var pack : order) {
420         if (skip.contains(pack.name()))
421            continue;
422         var vendor = pack.kind() == VENDOR;
423         addUrls(pack, pack.cssPaths(), vendor ? vendorCss : runtimeCss, req, pageFiles, template);
424         addUrls(pack, pack.jsPaths(), vendor ? vendorJs : runtimeJs, req, pageFiles, template);
425      }
426      return new Resolved(List.copyOf(vendorCss), List.copyOf(vendorJs), List.copyOf(runtimeCss), List.copyOf(runtimeJs));
427   }
428
429   /**
430    * The file name of a URL: the last path segment, without query or fragment, lower-cased.
431    *
432    * @param url The URL.
433    * @return The file name.
434    */
435   static String fileName(String url) {
436      var s = url;
437      var q = s.indexOf('?');
438      if (q >= 0)
439         s = s.substring(0, q);
440      var h = s.indexOf('#');
441      if (h >= 0)
442         s = s.substring(0, h);
443      return s.substring(s.lastIndexOf('/') + 1).toLowerCase(Locale.ROOT);
444   }
445
446   private static void addUrls(ToolkitPack pack, List<String> paths, List<String> out, RestRequest req, Map<String,String> pageFiles, String template) {
447      for (var path : paths) {
448         String url;
449         try {
450            url = pack.resolver().resolve(req, path);
451         } catch (IllegalStateException | IllegalArgumentException e) {
452            var x = e instanceof IllegalStateException ? webJarMissing(pack, path, e) : null;
453            throw x != null ? x : new IllegalStateException("Pack '" + pack.name() + "' " + e.getMessage(), e);
454         }
455         var dup = pageFiles.get(fileName(url));
456         if (nn(dup)) {
457            LOG.log(Level.WARNING, () -> f("Template '%s': skipped pack '%s' asset %s; the page already loads %s.", template, pack.name(), url, dup));
458            continue;
459         }
460         out.add(url);
461      }
462   }
463
464   // Caller holds the monitor.
465   private List<ToolkitPack> expand(Collection<String> roots, String source) {
466      var out = new LinkedHashMap<String,ToolkitPack>();
467      for (var name : roots)
468         visit(name, source, new ArrayList<>(), out);
469      return List.copyOf(out.values());
470   }
471
472   private void visit(String name, String source, List<String> path, Map<String,ToolkitPack> out) {
473      if (out.containsKey(name))
474         return;
475      var at = path.indexOf(name);
476      if (at >= 0) {
477         var cycle = tl(path.subList(at, path.size()));
478         cycle.add(name);
479         throw new IllegalArgumentException("Toolkit pack cycle: " + String.join(" → ", cycle) + ".");
480      }
481      var pack = packs.get(name);
482      if (n(pack))
483         throw iaex("Unknown toolkit pack '%s' (%s).", name, source);
484      path.add(name);
485      for (var dep : pack.dependsOn())
486         visit(dep, "dependsOn of pack '" + name + "'", path, out);
487      path.remove(path.size() - 1);
488      out.put(name, pack);
489   }
490}