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.util.*;
022import java.util.function.*;
023import java.util.regex.*;
024import java.util.stream.*;
025
026import org.apache.juneau.marshall.collections.*;
027import org.apache.juneau.rest.server.*;
028import org.apache.juneau.console.*;
029import org.apache.juneau.rest.server.view.freemarker.*;
030import org.apache.juneau.console.views.*;
031
032import freemarker.template.*;
033
034/**
035 * Java description of one console page: the active tab, toolkits, page CSS and init scripts, per-request header
036 * fields, the stock theme by name, and the page's cards (tables, html and registered custom types) and body attributes.
037 *
038 * <p>
039 * A {@code PageSpec} renders nothing itself.  Its values are seeded into the request's {@link PageCapture} before any
040 * directive runs, so a built page goes through the same {@code <@console>} chrome and emits the same
041 * {@code #juneau-page} contract and the same toolkit asset tags as an FTL page.  Chrome (brand, nav tree, footer, theme tokens) stays in the app's
042 * chrome template.
043 *
044 * <h5 class='section'>Example:</h5>
045 * <p class='bjava'>
046 *    <jk>var</jk> <jv>spec</jv> = PageSpec.<jsm>create</jsm>()
047 *       .title(<js>"SLO"</js>)
048 *       .tab(<js>"slo"</js>)
049 *       .toolkit(<js>"views"</js>)
050 *       .css(<js>"/css/slo.css"</js>)
051 *       .csrf(<jv>token</jv>, <js>"X-CSRF"</js>)
052 *       .html(<js>"intro"</js>, <js>"&lt;p&gt;Queue health&lt;/p&gt;"</js>)
053 *       .table(<jv>sloTable</jv>);
054 * </p>
055 *
056 * <h5 class='section'>Example:</h5>
057 * <p class='bjava'>
058 *    <jk>return</jk> PageSpec.<jsm>create</jsm>()
059 *       .tab(<js>"changes"</js>)
060 *       .table(ChangesView.<jsm>def</jsm>(), <jv>c</jv> -&gt; <jv>c</jv>.title(<js>"Changes"</js>))
061 *       .table(TasksView.<jsm>def</jsm>(), <jv>c</jv> -&gt; <jv>c</jv>.title(<js>"Tasks for the selected change"</js>)
062 *          .subscribes(Subscription.<jsm>to</jsm>(Topics.<jsm>selection</jsm>(<js>"changes"</js>)).as(<js>"params"</js>).map(<js>"changeId"</js>, <js>"ids.0"</js>)))
063 *       .card(ChartDef.<jsm>bar</jsm>(<js>"byOwner"</js>).title(<js>"Open changes by owner"</js>).src(<js>"/rest/changes/by-owner?q={q}"</js>)
064 *          .x(<js>"owner"</js>).series(<js>"open"</js>, <js>"Open"</js>).toCardSpec()
065 *          .subscribes(Subscription.<jsm>to</jsm>(Topics.<jsm>filter</jsm>(<js>"changes"</js>)).as(<js>"params"</js>).map(<js>"q"</js>, <js>"search"</js>)))
066 *       .view(<jv>req</jv>);
067 * </p>
068 *
069 * @since 10.0.0
070 */
071@SuppressWarnings({
072   "java:S1845", // Fluent-builder setters intentionally mirror field names (Juneau DSL convention)
073   "java:S3776", // Validation and rendering chains many independent guards; splitting them adds no clarity.
074   "javabugs:S2259" // False positive: each card.accept(c) is guarded by nn(card), which Sonar's flow analysis does not track as a null guard.
075})
076public final class PageSpec {
077
078   /** Data-model attribute that carries the spec into the render. */
079   public static final String ATTR = "jcPageSpec";
080
081   private static final Set<String> USER_MENU_KEYS = Set.of("label", "initials", "avatar", "items");
082   /**
083    * The three reserved types ({@code chart}, {@code job-log}, {@code terminal}) {@link #applyTo} lifts through their registered handler,
084    * with their own {@code <@card>} checks.  The other reserved types are seeded as written; adopter types are lifted
085    * through the registry too.
086    */
087   private static final Set<String> LIFTED_TYPES = Set.of("chart", "job-log", "terminal");
088   private static final Pattern ATTR_NAME_RE = Pattern.compile("[A-Za-z_:][-A-Za-z0-9_:.]*");
089
090   /** One {@code header(String,String)} passthrough entry. */
091   record HeaderEntry(String name, String value) {}
092
093   private String template;
094   private String title;
095   private String tab;
096   String theme;
097   private String scheme;
098   private boolean schemeToggle;
099   final List<String> toolkits = new ArrayList<>();
100   final List<String> css = new ArrayList<>();
101   final List<String> init = new ArrayList<>();
102   final Map<String,Object> attrs = new LinkedHashMap<>();
103   final List<HeaderEntry> headers = new ArrayList<>();
104   String headerSubtitle;
105   Map<String,Object> headerUserMenu;
106   final List<CardSpec> cards = new ArrayList<>();
107   /** Card id to inline markup; an absent key means no markup. */
108   final Map<String,String> cardMarkup = new LinkedHashMap<>();
109   /** Card id to the unwrapped datatables body of each {@link #table} card, handed to the card-type registry at seed time. */
110   final Map<String,Map<String,Object>> tableBodies = new LinkedHashMap<>();
111   final List<TopicDecl> topics = new ArrayList<>();
112   private final Map<String,Boolean> topicRetain = new LinkedHashMap<>();
113   final Map<String,BridgeDecl> bridges = new LinkedHashMap<>();
114   final Map<String,JsonMap> dataSources = new LinkedHashMap<>();
115   /** The {@code dashboard} contract object, or <jk>null</jk> (see {@link #dashboard(DashboardSpec)}). */
116   private Map<String,Object> dashboard;
117   /** Body attribute name to its HTML-attribute-escaped value; the last call for a name wins. */
118   final Map<String,String> bodyAttrs = new LinkedHashMap<>();
119   final List<PageCapture.PendingNavAdd> navUnders = new ArrayList<>();
120   final List<BadgeDef> badges = new ArrayList<>();
121   Map<String,Object> facts = Map.of();
122
123   private PageSpec() {}
124
125   /**
126    * Creates a new, empty spec.
127    *
128    * @return A new spec.
129    */
130   public static PageSpec create() {
131      return new PageSpec();
132   }
133
134   /**
135    * Sets the page template, overriding the mixin's configured chrome template.
136    *
137    * @param pageTemplateName The template name (no {@code .ftlh}).
138    * @return This object.
139    */
140   public PageSpec template(String pageTemplateName) {
141      template = pageTemplateName;
142      return this;
143   }
144
145   /**
146    * Adds a passthrough attribute, applied to the view this spec builds.
147    *
148    * @param key The attribute name.
149    * @param value The attribute value.
150    * @return This object.
151    */
152   public PageSpec attr(String key, Object value) {
153      if (ATTR.equals(key))
154         throw iaex("PageSpec.attr key '%s' is reserved for the spec itself.", key);
155      attrs.put(key, value);
156      return this;
157   }
158
159   /**
160    * Adds a passthrough HTTP response header, applied to the view this spec builds.
161    *
162    * @param name The header name.
163    * @param value The header value.
164    * @return This object.
165    */
166   public PageSpec header(String name, String value) {
167      headers.add(new HeaderEntry(name, value));
168      return this;
169   }
170
171   /**
172    * Sets the page title.  The spec always wins over {@code <@console title>}.
173    *
174    * @param v The title.
175    * @return This object.
176    */
177   public PageSpec title(String v) {
178      title = v;
179      return this;
180   }
181
182   /**
183    * Sets the {@code <@page tab>}-equivalent active-nav path.
184    *
185    * @param idPath A {@code '/'}-separated id path, e.g. {@code "home/skills/core"}.
186    * @return This object.
187    * @throws IllegalArgumentException If blank, or any segment is blank.
188    */
189   public PageSpec tab(String idPath) {
190      tab = checkIdPath("tab", idPath);
191      return this;
192   }
193
194   /**
195    * Validates a {@code '/'}-separated id path: the whole string and every segment must be non-blank.
196    *
197    * @param method The calling method's name, used in the error message.
198    * @param idPath The path to validate.
199    * @return {@code idPath}, unchanged.
200    * @throws IllegalArgumentException If blank, or any {@code '/'}-separated segment is blank.
201    */
202   static String checkIdPath(String method, String idPath) {
203      if (ib(idPath))
204         throw iaex("PageSpec %s path must be a non-empty '/'-separated id path; got '%s'.", method, idPath);
205      for (var segment : idPath.split("/", -1))
206         if (segment.isBlank())
207            throw iaex("PageSpec %s path must be a non-empty '/'-separated id path; got '%s'.", method, idPath);
208      return idPath;
209   }
210
211   /**
212    * Selects a stock theme by name.  Validated immediately via {@link ConsoleChromeMixin#stockTheme(String)}.
213    *
214    * @param stockName One of {@link ConsoleChromeMixin#BUILTIN_THEME_NAMES}.
215    * @return This object.
216    * @throws IllegalArgumentException If {@code stockName} is not a built-in stock-theme name.
217    */
218   public PageSpec theme(String stockName) {
219      ConsoleChromeMixin.stockTheme(stockName);
220      theme = stockName;
221      return this;
222   }
223
224   /**
225    * Pins the page's colour scheme.
226    *
227    * <p>
228    * {@code auto} (the default) follows the browser's {@code prefers-color-scheme}; {@code light} and {@code dark}
229    * pin the page. An explicit {@code <@console scheme="…">} on the page template wins over this value.
230    *
231    * @param name One of {@link ConsoleChromeMixin#SCHEMES}.
232    * @return This object.
233    * @throws IllegalArgumentException If {@code name} is not a known scheme.
234    */
235   public PageSpec scheme(String name) {
236      if (name == null || ! ConsoleChromeMixin.SCHEMES.contains(name))
237         throw iaex("Unknown scheme '%s'.  Schemes: %s.", name, String.join(", ", ConsoleChromeMixin.SCHEMES));
238      scheme = name;
239      return this;
240   }
241
242   /**
243    * Lets the visitor override the page's colour scheme with a stored choice.
244    *
245    * <p>
246    * An explicit {@code <@console schemeToggle="…">} on the page template wins over this value.
247    *
248    * @param value Whether the stored choice is honoured.
249    * @return This object.
250    */
251   public PageSpec schemeToggle(boolean value) {
252      schemeToggle = value;
253      return this;
254   }
255
256   /**
257    * Adds toolkit names, appending any not already present.
258    *
259    * @param names The toolkit names.
260    * @return This object.
261    */
262   public PageSpec toolkit(String...names) {
263      addAllDeduped(toolkits, names);
264      return this;
265   }
266
267   /**
268    * Adds page CSS URLs, appending any not already present.
269    *
270    * @param urls The CSS URLs.
271    * @return This object.
272    */
273   public PageSpec css(String...urls) {
274      addAllDeduped(css, urls);
275      return this;
276   }
277
278   /**
279    * Adds page init-script URLs, appending any not already present.
280    *
281    * @param urls The script URLs.
282    * @return This object.
283    */
284   public PageSpec init(String...urls) {
285      addAllDeduped(init, urls);
286      return this;
287   }
288
289   private static void addAllDeduped(List<String> target, String...values) {
290      for (var v : values)
291         if (! target.contains(v))
292            target.add(v);
293   }
294
295   /**
296    * Sets the header's {@code subtitle} and/or {@code userMenu}.  These are the only two header fields this builder
297    * may set; every other field belongs in the chrome template.
298    *
299    * @param c Mutates a scratch {@link PageCapture.HeaderSpec}.
300    * @return This object.
301    * @throws IllegalArgumentException If {@code c} sets any field other than {@code subtitle}/{@code userMenu}, or the
302    *    user menu has a key the page contract does not allow.
303    */
304   public PageSpec header(Consumer<PageCapture.HeaderSpec> c) {
305      var scratch = new PageCapture.HeaderSpec();
306      c.accept(scratch);
307      checkHeaderScratch(scratch);
308      if (scratch.subtitle != null)
309         headerSubtitle = scratch.subtitle;
310      if (scratch.userMenu != null)
311         for (var k : scratch.userMenu.keySet())
312            if (! USER_MENU_KEYS.contains(k))
313               throw iaex("PageSpec.header userMenu key '%s' is not allowed; allowed: label, initials, avatar, items.", k);
314      if (scratch.userMenu != null)
315         headerUserMenu = new LinkedHashMap<>(scratch.userMenu);
316      return this;
317   }
318
319   private static void checkHeaderScratch(PageCapture.HeaderSpec h) {
320      if (h.title != null)
321         throw headerFieldRejected("title");
322      if (h.logoSrc != null || h.logoHref != null || h.logoAlt != null)
323         throw headerFieldRejected("logo");
324      if (h.chrome)
325         throw headerFieldRejected("chrome");
326      if (! h.links.isEmpty())
327         throw headerFieldRejected("link");
328   }
329
330   private static IllegalArgumentException headerFieldRejected(String field) {
331      return iaex("PageSpec.header may set only subtitle and userMenu; '%s' belongs in the chrome template.", field);
332   }
333
334   /**
335    * Appends a card, enforcing unique ids across every spec-declared card.
336    *
337    * @param card The card.
338    * @return {@code card}, for fluent chaining by the caller.
339    * @throws IllegalArgumentException If a card with this id was already declared.
340    */
341   CardSpec addCard(CardSpec card) {
342      for (var c : cards)
343         if (c.id().equals(card.id()))
344            throw iaex("PageSpec card id '%s' is already declared.", card.id());
345      cards.add(card);
346      return card;
347   }
348
349   /**
350    * Adds a {@code datatables} card for {@code table}, with no title.
351    *
352    * @param table The table.  Validated and snapshotted immediately; later changes to it do not affect this page.
353    * @return This object.
354    */
355   public PageSpec table(TableSpec table) {
356      return table(table, (Consumer<CardSpec>)null);
357   }
358
359   /**
360    * Adds a {@code datatables} card for {@code table}, with a title.
361    *
362    * @param table The table.
363    * @param title The card title.
364    * @return This object.
365    */
366   public PageSpec table(TableSpec table, String title) {
367      return table(table, c -> c.title(title));
368   }
369
370   /**
371    * Adds a {@code datatables} card for {@code table}, letting {@code card} adjust the card.
372    *
373    * <p>
374    * The table's CSS class, if set, is carried onto the card wrapper before {@code card} runs, so {@code card} may
375    * override it.  The card body is produced by the card-type registry when the spec is seeded into a page.
376    *
377    * @param table The table.
378    * @param card Mutates the card.  May be <jk>null</jk>.
379    * @return This object.
380    */
381   public PageSpec table(TableSpec table, Consumer<CardSpec> card) {
382      table.validate();
383      var body = table.toCardBody();
384      var c = CardSpec.of("datatables", table.id());
385      if (nn(table.cssClass()))
386         c.cssClass(table.cssClass());
387      if (nn(card))
388         card.accept(c);
389      if (nn(c.src()) || nn(c.template()))
390         throw iaex("PageSpec.table id '%s' sets src or template; the table supplies the card's data.", table.id());
391      addCard(c);
392      tableBodies.put(table.id(), body);
393      return this;
394   }
395
396   /**
397    * Adds an {@code html} card whose body is {@code markup}, with no title.
398    *
399    * @param id The card id.
400    * @param markup The markup, emitted as {@code <template data-card=id>}.  Must not be <jk>null</jk>.
401    * @return This object.
402    * @throws IllegalArgumentException If {@code markup} is <jk>null</jk>.
403    */
404   public PageSpec html(String id, String markup) {
405      return html0(id, null, markup, null);
406   }
407
408   /**
409    * Adds an {@code html} card whose body is {@code markup}, with a title.
410    *
411    * @param id The card id.
412    * @param title The card title.
413    * @param markup The markup.  Must not be <jk>null</jk>.
414    * @return This object.
415    */
416   public PageSpec html(String id, String title, String markup) {
417      return html0(id, title, markup, null);
418   }
419
420   /**
421    * Adds an {@code html} card whose body is {@code markup}, letting {@code card} adjust the card (title, CSS
422    * class, and so on).  {@code card} must not also set {@code src} or {@code template}.
423    *
424    * @param id The card id.
425    * @param markup The markup.  Must not be <jk>null</jk>.
426    * @param card Mutates the card.  May be <jk>null</jk>.
427    * @return This object.
428    * @throws IllegalArgumentException If {@code markup} is <jk>null</jk>, or {@code card} sets {@code src} or {@code template}.
429    */
430   public PageSpec html(String id, String markup, Consumer<CardSpec> card) {
431      return html0(id, null, markup, card);
432   }
433
434   /**
435    * Adds an {@code html} card whose body is fetched from {@code src}, with a title, unsanitized.  See
436    * {@link #htmlSrc(String,String,String,boolean)} for the trust boundary.
437    *
438    * @param id The card id.
439    * @param title The card title.  May be <jk>null</jk> for no title.
440    * @param src The same-origin path of the HTML fragment endpoint.  Must not be blank.
441    * @return This object.
442    * @throws IllegalArgumentException If {@code src} is <jk>null</jk> or blank.
443    */
444   public PageSpec htmlSrc(String id, String title, String src) {
445      return htmlSrc(id, title, src, false);
446   }
447
448   /**
449    * Adds an {@code html} card whose body is fetched from {@code src}, with a title, optionally rebuilt through the
450    * views attribute-allowlist sanitizer.  The equivalent of {@code <@card type="html" src="..." sanitize=true>}.
451    *
452    * <h5 class='section'>Trust boundary:</h5>
453    * <p>
454    * With {@code sanitize} <jk>false</jk> the shell fetches {@code src} and inserts the returned HTML as-is through a
455    * raw HTML-sink assignment, <b>unsanitized</b>, the same trust level as {@code footer.text} (see
456    * {@code HtmlCardType}).  The endpoint must therefore be a trusted first-party route, must never be derived from
457    * request data, and must escape any data it embeds in the fragment.  {@code src} must be a same-origin path; an
458    * absolute URL, a scheme, {@code //}, {@code ..} or a backslash is refused when the page is built, exactly as for
459    * every other card.
460    *
461    * <p>
462    * With {@code sanitize} <jk>true</jk> the fragment is rebuilt through the views attribute-allowlist sanitizer
463    * instead: script, {@code on*} handlers, {@code javascript:} and {@code data:} URLs and every attribute off the
464    * allowlist are dropped, and benign markup (text, tables, links, images) survives.  It needs the views toolkit on
465    * the page; without the toolkit nothing is painted (the shell fails closed rather than inserting the fragment
466    * unsanitized).  The sanitizer does not remove the need to escape any data you embed in the fragment.  Turn it on when the fragment's content is not fully under your control (it embeds user-supplied or
467    * third-party markup, or comes from an endpoint you do not own); leave it off for a fragment that needs
468    * {@code class}, {@code id}, {@code style}, forms, SVG or iframes, which the allowlist drops.  The default stays off.
469    *
470    * @param id The card id.
471    * @param title The card title.  May be <jk>null</jk> for no title.
472    * @param src The same-origin path of the HTML fragment endpoint.  Must not be blank.
473    * @param sanitize Whether to rebuild the fetched fragment through the sanitizer.
474    * @return This object.
475    * @throws IllegalArgumentException If {@code src} is <jk>null</jk> or blank.
476    */
477   public PageSpec htmlSrc(String id, String title, String src, boolean sanitize) {
478      if (ib(src))
479         throw iaex("PageSpec.htmlSrc id '%s' requires a non-blank src.", id);
480      var c = CardSpec.html(id).src(src).sanitize(sanitize);
481      if (nn(title))
482         c.title(title);
483      addCard(c);
484      return this;
485   }
486
487   private PageSpec html0(String id, String title, String markup, Consumer<CardSpec> card) {
488      if (markup == null)
489         throw iaex("PageSpec.html id '%s' requires a non-null markup.", id);
490      var c = CardSpec.html(id);
491      if (nn(title))
492         c.title(title);
493      if (nn(card)) {
494         card.accept(c);
495         if (nn(c.src()) || nn(c.template()))
496            throw iaex("PageSpec.html id '%s' sets a markup body; its Consumer<CardSpec> must not also set src or template.", id);
497      }
498      addCard(c);
499      cardMarkup.put(id, markup);
500      return this;
501   }
502
503   /**
504    * Adds a custom-type card with no inline markup.
505    *
506    * <p>
507    * When the page renders, a card of an adopter type (any type that is not reserved) is built as
508    * {@code <@card>} builds it: its body map is the authored body, the type's registered {@link CardTypeHandler}
509    * validates it and returns the fragment the card carries, and an invalid body fails the render with the
510    * {@code <@card>} message.  An unregistered type takes the generic passthrough, so its body map is carried as-is.
511    *
512    * @param card The card.  Its type must not be a removed type or one with a dedicated method.
513    * @return This object.
514    */
515   public PageSpec card(CardSpec card) {
516      return card(card, null);
517   }
518
519   /**
520    * Adds a custom-type card that also takes inline markup.
521    *
522    * @param card The card.  See {@link #card(CardSpec)}.
523    * @param markupOrNull The markup, or <jk>null</jk>.  For an adopter type it is the card's body, as the content of
524    *    {@code <@card>} is: the handler parses it (JSON5) or captures it as {@code <template data-card=card.id()>}, and
525    *    {@code subscribes} / {@code publishes} keys in JSON5 markup are wired onto the card.  A card takes at most one of a
526    *    body map, non-blank markup, {@code template} or {@code src}.
527    * @return This object.
528    */
529   public PageSpec card(CardSpec card, String markupOrNull) {
530      checkCardType(card);
531      addCard(card);
532      if (nn(markupOrNull))
533         cardMarkup.put(card.id(), markupOrNull);
534      return this;
535   }
536
537   /**
538    * Adds the page's dashboard: a rail of filter sections beside a grid of card panels.
539    *
540    * <p>
541    * The panel and card-section cards built by the dashboard are added to this page as ordinary cards, and the
542    * {@code dashboard} toolkit pack is added (just before {@code charts} when that is listed).  A by-id panel
543    * ({@code panel("notes")}) must name a card added <i>before</i> this call, for example with {@link #html(String, String)}.
544    * </p>
545    *
546    * @param dashboard The dashboard.
547    * @return This object.
548    * @throws IllegalArgumentException E-77 on any dashboard problem, or if the page already has a dashboard.
549    */
550   public PageSpec dashboard(DashboardSpec dashboard) {
551      if (dashboard == null)
552         throw iaex("PageSpec.dashboard requires a dashboard.");
553      if (this.dashboard != null)
554         throw Section.e77(dashboard.id(), "a page has at most one dashboard");
555      var byId = new LinkedHashMap<String,CardSpec>();
556      for (var c : cards)
557         byId.put(c.id(), c);
558      var json = dashboard.toJson(byId);
559      for (var c : dashboard.ownedCards())
560         card(c);
561      var pack = ToolkitPackRegistry.PACK_DASHBOARD;
562      if (! toolkits.contains(pack)) {
563         var charts = toolkits.indexOf(ToolkitPackRegistry.PACK_CHARTS);
564         if (charts >= 0)
565            toolkits.add(charts, pack);
566         else
567            toolkits.add(pack);
568      }
569      this.dashboard = json;
570      return this;
571   }
572
573   private static void checkCardType(CardSpec card) {
574      if (card == null)
575         throw iaex("PageSpec.card requires a non-null card.");
576      var type = card.type();
577      if (ib(type))
578         throw iaex("PageSpec.card id '%s' requires a non-blank type.", card.id());
579      if (CardDirectiveModel.REMOVED_TYPES.contains(type))
580         throw iaex("PageSpec.card type '%s' was removed in 10.0.0; use PageSpec.html(...) with a markup/template, or a registered card type.", type);
581      if ("html".equals(type))
582         throw iaex("PageSpec.card type '%s' has a dedicated method; use PageSpec.%s(...).", type, "html");
583      if ("datatables".equals(type))
584         throw iaex("PageSpec.card type '%s' has a dedicated method; use PageSpec.%s(...).", type, "table");
585      var registry = CardTypeRegistry.standard();
586      if (CardTypeRegistry.RESERVED.contains(type) && ! registry.isRegistered(type))
587         throw iaex("PageSpec.card type '%s' is reserved and has no card-type handler; it cannot be built with PageSpec.", type);
588   }
589
590   /**
591    * Sets the CSRF body attributes: {@code data-juneau-csrf} and, if {@code headerName} is non-blank,
592    * {@code data-juneau-csrf-header}.
593    *
594    * @param token The CSRF token value.
595    * @param headerName The CSRF header name, or <jk>null</jk>/blank to omit the header attribute.
596    * @return This object.
597    */
598   public PageSpec csrf(String token, String headerName) {
599      if (token == null)
600         throw iaex("PageSpec.csrf requires a non-null token.");
601      bodyAttrs.put("data-juneau-csrf", attrEscape(token));
602      if (nn(headerName) && ! headerName.isBlank())
603         bodyAttrs.put("data-juneau-csrf-header", attrEscape(headerName));
604      return this;
605   }
606
607   /**
608    * Sets one {@code <body>} attribute.
609    *
610    * @param name The attribute name.
611    * @param value The attribute value; {@code & < > "} are escaped.
612    * @return This object.
613    */
614   public PageSpec bodyAttr(String name, String value) {
615      if (name == null || ! ATTR_NAME_RE.matcher(name).matches())
616         throw iaex("PageSpec.bodyAttr name '%s' must match [A-Za-z_:][-A-Za-z0-9_:.]*.", name);
617      bodyAttrs.put(name, attrEscape(value));
618      return this;
619   }
620
621   private static String attrEscape(String s) {
622      return s == null ? "" : s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;");
623   }
624
625   /** @return The declared cards, in declaration order; a snapshot. */
626   public List<CardSpec> cards() {
627      return List.copyOf(cards);
628   }
629
630   /**
631    * Looks up a declared card by id.
632    *
633    * @param id The card id.
634    * @return The card, or empty if none was declared with this id.
635    */
636   public Optional<CardSpec> card(String id) {
637      return cards.stream().filter(c -> c.id().equals(id)).findFirst();
638   }
639
640   /**
641    * Adds a header badge.  The Java twin of {@code <@badge>}.
642    *
643    * @param def The badge.  Must not be <jk>null</jk>.
644    * @return This object.
645    * @throws IllegalArgumentException If {@link BadgeDef#validate()} rejects it.
646    */
647   public PageSpec badge(BadgeDef def) {
648      def.validate();
649      badges.add(def);
650      return this;
651   }
652
653   /**
654    * Seeds page-level facts, which {@code visibleWhen} rules test on the client.  Repeated calls merge, last wins, on
655    * this side only; the strict check against chrome-supplied facts runs once, at {@code </@console>}.
656    *
657    * @param v The facts.  Leaves must be strings, numbers, booleans, string lists or maps.
658    * @return This object.
659    * @throws IllegalArgumentException On a leaf of any other type.
660    */
661   public PageSpec facts(Map<String,Object> v) {
662      checkFactTypes("", v);
663      facts = PageCapture.mergeFactsLastWins(facts, copyFacts(v));
664      return this;
665   }
666
667   // Deep, unmodifiable copy, so a caller that later mutates its maps or lists cannot change the page.
668   private static Map<String,Object> copyFacts(Map<String,Object> m) {
669      var out = new LinkedHashMap<String,Object>();
670      for (var e : m.entrySet())
671         out.put(e.getKey(), copyFact(e.getValue()));
672      return u(out);
673   }
674
675   @SuppressWarnings({
676      "unchecked" // Narrows a JSON-shaped Map<?,?> to Map<String,Object>; its keys are always strings.
677   })
678   private static Object copyFact(Object v) {
679      if (v instanceof Map<?,?> nested)
680         return copyFacts((Map<String,Object>)nested);
681      if (v instanceof List<?> list)
682         return List.copyOf(list);
683      return v;
684   }
685
686   static void checkFactTypes(String prefix, Map<?,?> m) {
687      for (var e : m.entrySet()) {
688         var path = prefix.isEmpty() ? String.valueOf(e.getKey()) : prefix + "." + e.getKey();
689         var v = e.getValue();
690         if (v instanceof Map<?,?> nested) {
691            checkFactTypes(path, nested);
692         } else if (v instanceof List<?> list) {
693            for (var item : list)
694               if (! (item instanceof String))
695                  throw badFact(path, item);
696         } else if (! (v instanceof String || v instanceof Boolean || isPlainNumber(v))) {
697            throw badFact(path, v);
698         }
699      }
700   }
701
702   private static boolean isPlainNumber(Object v) {
703      return v instanceof Integer || v instanceof Long || v instanceof Short || v instanceof Byte || v instanceof Float
704         || v instanceof Double || v instanceof java.math.BigInteger || v instanceof java.math.BigDecimal;
705   }
706
707   private static IllegalArgumentException badFact(String path, Object v) {
708      return iaex("Fact '%s' has unsupported value type '%s'; use string, number, boolean, string list or map.",
709         path, v == null ? "null" : v.getClass().getSimpleName());
710   }
711
712   /**
713    * Java twin of the {@code </@console>} card-reference check, run early when this spec's own card set is final.  The
714    * nav-node check needs the resolved chrome, so it only runs at {@code </@console>}.
715    */
716   private void checkBadgesAgainstOwnCards() {
717      var cardIds = cards.stream().map(CardSpec::id).collect(Collectors.toSet());
718      for (var b : badges) {
719         var m = b.toContractMap();
720         for (var card : PageCapture.badgeCardRefs(m))
721            if (! cardIds.contains(card))
722               throw isex(PageCapture.BADGE_CARD_MSG, m.get("id"), card);
723      }
724   }
725
726   /**
727    * Queues a child to add under an existing chrome nav node, resolved once the chrome's own nav tree exists.
728    *
729    * <p>
730    * The Java twin of {@code <@node under="...">}.
731    *
732    * @param parentIdPath A {@code '/'}-separated id path to the existing ancestor node.
733    * @param adder Receives the resolved ancestor and adds its children to it.
734    * @return This object.
735    * @throws IllegalArgumentException If the path is blank, or any {@code '/'}-separated segment is blank.
736    */
737   public PageSpec navUnder(String parentIdPath, PageCapture.NavAdder adder) {
738      navUnders.add(new PageCapture.PendingNavAdd(checkIdPath("navUnder", parentIdPath), adder));
739      return this;
740   }
741
742   /**
743    * Declares a page-level custom topic; repeatable.  Unions with FTL {@code <@topic>}.
744    *
745    * @param decl The declaration; needs {@code retain} and {@code publisher}.
746    * @return This object.
747    * @throws IllegalArgumentException E-46 if the topic is already declared with a different {@code retain} or {@code publisher}.
748    */
749   public PageSpec topic(TopicDecl decl) {
750      var m = decl.toMap();
751      var retain = (Boolean)m.get("retain");
752      var prior = topicRetain.putIfAbsent(decl.topic(), retain);
753      if (prior != null && ! prior.equals(retain))
754         throw iaex("topic '%s' is declared with retain=%s here and retain=%s at %s", decl.topic(), retain, prior, "PageSpec.topic");
755      if (prior == null) {
756         topics.add(decl);
757      } else {
758         var priorPub = topics.stream().filter(t -> t.topic().equals(decl.topic())).findFirst().get().toMap().get("publisher");
759         if (! priorPub.equals(m.get("publisher")))
760            throw iaex("topic '%s' is declared with publisher=%s here and publisher=%s at %s", decl.topic(), m.get("publisher"), priorPub, "PageSpec.topic");
761      }
762      return this;
763   }
764
765   /**
766    * Declares a server bridge; repeatable.  Unions with FTL {@code <@bridge>}.
767    *
768    * @param decl The bridge.
769    * @return This object.
770    * @throws IllegalArgumentException E-51 on a duplicate id, or from {@link BridgeDecl#toMap()}.
771    */
772   public PageSpec bridge(BridgeDecl decl) {
773      decl.toMap();   // fail at the call site, not at render
774      if (bridges.putIfAbsent(decl.id(), decl) != null)
775         throw iaex("bridge '%s': %s", decl.id(), "duplicate id");
776      return this;
777   }
778
779   /**
780    * Declares a data source, published on {@code data:<id>}; repeatable.
781    *
782    * @param def The source.
783    * @return This object.
784    * @throws IllegalArgumentException E-76 on a duplicate id or a bad declaration; also on a null definition.
785    */
786   public PageSpec data(DataSourceDef def) {
787      if (def == null)
788         throw iaex("PageSpec.data requires a non-null definition.");
789      return addData(def.toMap());   // fail at the call site, not at render
790   }
791
792   /**
793    * Declares a derived data source, published on {@code data:<as>}; repeatable.
794    *
795    * @param def The derived source.
796    * @return This object.
797    * @throws IllegalArgumentException E-76 on a duplicate id or a bad declaration; also on a null definition.
798    * @throws IllegalStateException If {@link Derived#as(String)} was not called.
799    */
800   public PageSpec data(Derived def) {
801      if (def == null)
802         throw iaex("PageSpec.data requires a non-null definition.");
803      return addData(def.toMap());
804   }
805
806   private PageSpec addData(JsonMap m) {
807      var id = (String)m.get("id");
808      if (dataSources.putIfAbsent(id, m) != null)
809         throw iaex(DataSourceDef.E76, id, "duplicate id");
810      return this;
811   }
812
813   /** @return The template name, or <jk>null</jk> if unset (the mixin's chrome template applies). */
814   public String template() {
815      return template;
816   }
817
818   /** @return The tab path, or {@code ""} if unset. */
819   public String tab() {
820      return tab == null ? "" : tab;
821   }
822
823   /** @return The toolkit names, in declaration order. */
824   public List<String> toolkits() {
825      return List.copyOf(toolkits);
826   }
827
828   /**
829    * The per-render holder {@link #view(RestRequest)} puts in the data model.  It carries this spec and records
830    * whether the render adopted it, so a spec reused across requests never shares adoption state.
831    */
832   static final class Seed implements FreemarkerView.MustConsume {
833
834      private final PageSpec spec;
835      private volatile boolean consumed;
836
837      Seed(PageSpec spec) {
838         this.spec = spec;
839      }
840
841      PageSpec spec() { return spec; }
842
843      /** Applies the spec to a fresh capture and marks it adopted. */
844      void adoptInto(PageCapture cap, CardDirectiveModel cardModel) throws TemplateModelException {
845         spec.applyTo(cap, cardModel);
846         consumed = true;
847      }
848
849      void markConsumed() { consumed = true; }
850
851      @Override /* Overridden from FreemarkerView.MustConsume */
852      public boolean consumed() { return consumed; }
853
854      @Override /* Overridden from FreemarkerView.MustConsume */
855      public String notConsumedMessage(String templateName) {
856         return f("Template '%s' was rendered with a PageSpec, but the spec was never adopted; the "
857            + "template must use <@page> or include the console chrome.", templateName);
858      }
859   }
860
861   /**
862    * Builds the view for this spec.
863    *
864    * <p>
865    * The template is {@link #template(String)} when set, else the {@code ConsoleFreemarkerMixin}'s chrome template.
866    * A built spec is not modified by rendering, so it may be cached and reused across requests.  Call
867    * {@code spec.view(req)} on each request: a reused {@link FreemarkerView} would share one adoption flag.
868    *
869    * @param req The current REST request.
870    * @return A view for the template, carrying this spec and every {@link #attr(String, Object)} /
871    *    {@link #header(String, String)} passthrough.
872    * @throws IllegalStateException If no template was set and the request has no {@code ConsoleFreemarkerMixin} bean.
873    * @throws IllegalArgumentException If this spec has no page template and its bus wiring fails R-10 / R-11.
874    */
875   public FreemarkerView view(RestRequest req) {
876      if (req == null)
877         throw iaex("PageSpec.view requires a non-null request.");
878      String templateName;
879      if (template != null) {
880         templateName = template;
881      } else {
882         var ccm = resolveConsoleMixin(req);
883         checkWiringEarly(ccm.cardTypes(), req);
884         checkBadgesAgainstOwnCards();
885         templateName = ccm.chromeTemplate();
886      }
887      var v = FreemarkerView.of(templateName).attr(ATTR, new Seed(this));
888      for (var h : headers)
889         v = v.header(h.name(), h.value());
890      for (var e : attrs.entrySet())
891         v = v.attr(e.getKey(), e.getValue());
892      return v;
893   }
894
895   // The exact-type-else-registered-subtype lookup the renderer runs, without its two-beans ambiguity check (the
896   // renderer still reports that itself at render time).
897   @SuppressWarnings({
898      "resource" // The request context owns its bean store.
899   })
900   private static ConsoleFreemarkerMixin resolveConsoleMixin(RestRequest req) {
901      var store = req.getContext().getBeanStore();
902      FreemarkerMixin found = store.getBean(FreemarkerMixin.class).orElse(null);
903      if (found == null)
904         for (var t : FreemarkerMixin.registeredSubtypes()) {
905            var b = store.getBean(t).orElse(null);
906            if (b != null) {
907               found = b;
908               break;
909            }
910         }
911      if (! (found instanceof ConsoleFreemarkerMixin ccm))
912         throw isex("PageSpec.view needs a ConsoleFreemarkerMixin bean; found '%s'.",
913            found == null ? "none" : found.getClass().getSimpleName());
914      return ccm;
915   }
916
917   /**
918    * Applies every field this spec has set to {@code cap}, via {@code cap}'s plain (non-merge-aware) setters.
919    *
920    * <p>
921    * Called once per render, on a freshly created capture before any directive has written to it, so these raw
922    * writes can never conflict with anything.  This spec is not modified: each card is copied before it is seeded.
923    * A table card, a chart, job-log or terminal card, and a card of any adopter (non-reserved) type are lifted through the
924    * card-type registry of {@code cardModel}, with the {@code <@card>} checks and messages.  Every card's required packs are recorded through {@code cardModel}.
925    *
926    * @param cap The freshly created, still-empty capture.
927    * @param cardModel The shared {@code <@card>} directive; may be <jk>null</jk> only when this spec has no cards.
928    * @throws TemplateModelException If a card is rejected.
929    */
930   void applyTo(PageCapture cap, CardDirectiveModel cardModel) throws TemplateModelException {
931      if (title != null)
932         cap.title(title);
933      if (tab != null)
934         cap.tab(tab);
935      if (theme != null)
936         cap.theme(theme);
937      if (scheme != null)
938         cap.scheme(scheme);
939      if (schemeToggle)
940         cap.schemeToggle(true);
941      if (! toolkits.isEmpty())
942         cap.toolkit(toolkits);
943      if (! css.isEmpty())
944         cap.css(css);
945      if (! init.isEmpty())
946         cap.init(init);
947      if (headerSubtitle != null || headerUserMenu != null)
948         cap.header(h -> {
949            if (headerSubtitle != null)
950               h.subtitle(headerSubtitle);
951            if (headerUserMenu != null)
952               h.userMenu(headerUserMenu);
953         });
954      if (! cards.isEmpty() && n(cardModel))
955         throw FtlAttrLists.reject("PageSpec cards need the console card directive; the render has no ConsoleFreemarkerMixin.");
956      for (var card : cards) {
957         var markup = cardMarkup.get(card.id());
958         Built built;
959         if (tableBodies.containsKey(card.id()))
960            built = new Built(liftTable(card, cardModel), markup);
961         else if (LIFTED_TYPES.contains(card.type()))
962            built = new Built(liftChecked(card, cardModel), markup);
963         else if (CardTypeRegistry.RESERVED.contains(card.type()))
964            built = new Built(card.copy(), markup);
965         else
966            built = liftCustom(card, markup, cardModel);
967         cap.addBuiltCard(built.card(), built.markupOrNull());
968         cardModel.recordRequirements(cap, built.card().type(), built.card().id(), List.of(), built.card().bodyMap());
969      }
970      try {
971         for (var t : topics)
972            cap.addTopic(t.toMap(), "PageSpec.topic");
973         var req = FreemarkerRenderScope.request();
974         for (var b : bridges.values()) {
975            cap.addBridge(resolvedBridge(b, req));
976         }
977         for (var d : dataSources.values())
978            cap.addDataSource(new JsonMap(d));
979         if (! dataSources.isEmpty())
980            cap.require(List.of(ToolkitPackRegistry.PACK_DATA));
981      } catch (IllegalArgumentException e) {
982         throw FtlAttrLists.reject(e.getMessage());
983      }
984      if (! bodyAttrs.isEmpty()) {
985         var sb = new StringBuilder();
986         for (var e : bodyAttrs.entrySet())
987            sb.append(e.getKey()).append("=\"").append(e.getValue()).append("\" ");
988         cap.bodyAttrs = sb.toString().stripTrailing();
989      }
990      for (var p : navUnders)
991         cap.addPendingNavUnder(p.parentIdPath(), p.adder());
992      for (var b : badges)
993         cap.addBadge(b);
994      if (dashboard != null)
995         cap.dashboard(dashboard);
996      if (! facts.isEmpty())
997         cap.seedJavaFacts(facts);
998   }
999
1000   /** A card ready for {@link PageCapture#addBuiltCard}, with the markup it emits as its template, or <jk>null</jk>. */
1001   private record Built(CardSpec card, String markupOrNull) {}
1002
1003   // sanitize= is refused before any other check, as <@card> refuses it: only an html card with src= takes it.
1004   private static void checkSanitize(CardSpec card) throws TemplateModelException {
1005      if (card.sanitize())
1006         throw FtlAttrLists.reject(f("<@card id='%s'> sanitize= applies only to an html card with src=.", card.id()));
1007   }
1008
1009   private CardSpec liftTable(CardSpec card, CardDirectiveModel cardModel) throws TemplateModelException {
1010      checkSanitize(card);
1011      return liftVia(card, CardSource.create(card.type(), card.id()).bodyMap(tableBodies.get(card.id())), cardModel);
1012   }
1013
1014   private static CardSpec liftChecked(CardSpec card, CardDirectiveModel cardModel) throws TemplateModelException {
1015      checkSanitize(card);
1016      if (eq(card.type(), "terminal"))
1017         return liftTerminal(card, cardModel);
1018      if (eq(card.type(), "job-log"))
1019         rejectSrcAndTemplate(card);
1020      else if (nn(card.template()))
1021         throw FtlAttrLists.reject(f("<@card id='%s'> type='chart' takes a JSON5 body (plus src= for remote data); template= is not allowed.", card.id()));
1022      var source = CardSource.create(card.type(), card.id()).bodyMap(card.bodyMap());
1023      if (nn(card.src()))
1024         source.src(card.src());
1025      return liftVia(card, source, cardModel);
1026   }
1027
1028   /** Rejects {@code src=} and/or {@code template=} on a card whose options are its body, with the directive's message. */
1029   private static void rejectSrcAndTemplate(CardSpec card) throws TemplateModelException {
1030      var src = nn(card.src()) ? card.src() : "";
1031      var template = nn(card.template()) ? card.template() : "";
1032      if (! (src.isEmpty() && template.isEmpty()))
1033         throw FtlAttrLists.reject(f("<@card id='%s'> type='%s' takes its options as the body; %s.", card.id(), card.type(),
1034            CardDirectiveModel.notAllowed(src, template)));
1035   }
1036
1037   /**
1038    * A terminal card: its body map is the {@code {contractVersion: '1', terminal: {...}}} envelope {@code <@card>} takes,
1039    * with {@code contractVersion} implied, so the registered handler validates it exactly as it does for the directive.
1040    */
1041   private static CardSpec liftTerminal(CardSpec card, CardDirectiveModel cardModel) throws TemplateModelException {
1042      rejectSrcAndTemplate(card);
1043      var envelope = new LinkedHashMap<String,Object>();
1044      envelope.put("contractVersion", "1");
1045      envelope.putAll(card.bodyMap());
1046      return liftVia(card, CardSource.create(card.type(), card.id()).bodyMap(envelope), cardModel);
1047   }
1048
1049   /**
1050    * An adopter card type: the same checks {@code <@card>} runs, then its registered handler (or the generic
1051    * passthrough for an unregistered type) builds the fragment from the card's body map, or from its non-blank markup
1052    * when it has no body map.  The markup is emitted as the card's template only when the handler captures it, as
1053    * {@code <@card>} emits it; JSON5 markup a handler parses is the body, not a template.
1054    */
1055   private static Built liftCustom(CardSpec card, String markupOrNull, CardDirectiveModel cardModel) throws TemplateModelException {
1056      checkSanitize(card);
1057      var body = card.bodyMap();
1058      var hasMarkup = nn(markupOrNull) && ! markupOrNull.isBlank();
1059      if ((body.isEmpty() ? 0 : 1) + (hasMarkup ? 1 : 0) + (nn(card.src()) ? 1 : 0) + (nn(card.template()) ? 1 : 0) > 1)
1060         throw FtlAttrLists.reject(f("<@card id='%s'> type='%s' takes at most one of a body, template= or src=.", card.id(), card.type()));
1061      var captured = new boolean[1];
1062      var source = CardSource.create(card.type(), card.id()).templateSink((id, markup) -> captured[0] = true);
1063      if (nn(card.src()))
1064         source.src(card.src());
1065      if (nn(card.template()))
1066         source.template(card.template());
1067      if (! body.isEmpty())
1068         source.bodyMap(body);
1069      else if (hasMarkup)
1070         source.body(markupOrNull);
1071      var lifted = liftVia(card, source, cardModel);
1072      return new Built(lifted, captured[0] ? markupOrNull : null);
1073   }
1074
1075   private static CardSpec liftVia(CardSpec card, CardSource.Builder source, CardDirectiveModel cardModel) throws TemplateModelException {
1076      if (nn(card.title()))
1077         source.title(card.title());
1078      var lifted = cardModel.lift(source.build(), card);
1079      if (nn(card.cssClass()))
1080         lifted.cssClass(card.cssClass());
1081      if (card.bare())
1082         lifted.bare(true);
1083      return lifted;
1084   }
1085
1086   static Map<String,Object> resolvedBridge(BridgeDecl b, RestRequest req) {
1087      var m = b.toMap();
1088      var session = (String)m.get("session");
1089      if (nn(req))
1090         m.put("session", req.getUriResolver().resolve(session));
1091      else if (! session.startsWith("/"))
1092         throw iaex("BridgeDecl '%s' session '%s' needs a request to resolve.", b.id(), session);
1093      return m;
1094   }
1095
1096   /**
1097    * R-10 / R-11 over this spec alone, when it has no page template.  With a page template the FTL may add
1098    * publishers or cards, so R-10 waits for {@code </@console>}.
1099    *
1100    * @param registry The console mixin's card-type registry.
1101    * @param req The current request, used to resolve {@code servlet:} / {@code context:} bridge sessions; may be <jk>null</jk>
1102    *    only when no bridge uses one.
1103    * @throws IllegalArgumentException With the first wiring problem.
1104    */
1105   void checkWiringEarly(CardTypeRegistry registry, RestRequest req) {
1106      if (template != null)
1107         return;
1108      var early = new PageCapture();
1109      try {
1110         for (var c : cards) {
1111            var copy = c.copy();
1112            if (tableBodies.containsKey(c.id()))
1113               copy.body("table", tableBodies.get(c.id()));
1114            early.addCard(copy, null);
1115         }
1116      } catch (TemplateModelException e) {
1117         throw new IllegalArgumentException(e.getMessage(), e);
1118      }
1119      for (var t : topics)
1120         early.addTopic(t.toMap(), "PageSpec.topic");
1121      for (var b : bridges.values())
1122         early.addBridge(resolvedBridge(b, req));
1123      for (var d : dataSources.values())
1124         early.addDataSource(new JsonMap(d));
1125      if (dashboard != null)
1126         early.dashboard(dashboard);
1127      early.checkWiring(registry);
1128   }
1129}