001/*
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements.  See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to You under the Apache License, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License.  You may obtain a copy of the License at
008 *
009 *      http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.apache.juneau.console.freemarker;
018
019import static org.apache.juneau.commons.utils.Shorts.*;
020
021import java.io.*;
022import java.util.*;
023import java.util.function.*;
024import java.util.regex.*;
025import java.util.stream.*;
026
027import org.apache.juneau.commons.http.*;
028import org.apache.juneau.marshall.collections.*;
029import org.apache.juneau.marshall.marshaller.*;
030import org.apache.juneau.console.*;
031import org.apache.juneau.console.views.*;
032
033import freemarker.core.*;
034import freemarker.template.*;
035import freemarker.template.utility.*;
036
037/**
038 * Typed, per-render capture of one console page: theme, header, footer, nav tree, active nav, cards
039 * and slot templates, held as FreeMarker {@link Environment} custom state (see {@link #KEY}) so every
040 * directive in the render shares one instance. {@code <@console>} serializes it as the {@code #juneau-page}
041 * contract.
042 *
043 * <h5 class='section'>Example:</h5>
044 * <p class='bjava'>
045 *    <jc>// Inside a custom directive that contributes a card:</jc>
046 *    <jk>public void</jk> execute(Environment <jv>env</jv>, Map <jv>params</jv>, TemplateModel[] <jv>loop</jv>,
047 *          TemplateDirectiveBody <jv>body</jv>) <jk>throws</jk> TemplateException, IOException {
048 *       PageCapture <jv>page</jv> = PageCapture.<jsm>of</jsm>(<jv>env</jv>);
049 *       <jv>page</jv>.addCard(CardSpec.<jsm>html</jsm>(<js>"intro"</js>).title(<js>"Welcome"</js>),
050 *          PageCapture.<jsm>render</jsm>(<jv>body</jv>));
051 *    }
052 *
053 *    <jc>// In a test, after rendering:</jc>
054 *    String <jv>json</jv> = PageCapture.<jsm>get</jsm>(<jv>env</jv>).toContractJson();
055 * </p>
056 *
057 * @since 10.0.0
058 */
059@SuppressWarnings({
060   "java:S1192", // Duplicated literals read more clearly inline than as constants
061   "java:S3776" // Validation and rendering chains many independent guards; splitting them adds no clarity.
062})
063public final class PageCapture {
064
065   /** Environment custom-state key. */
066   public static final String KEY = PageCapture.class.getName();
067
068   /** The contract version this server writes. */
069   public static final String CONTRACT_VERSION = "1";
070
071   //-----------------------------------------------------------------------------------------------------------------
072   // Header / footer specs
073   //-----------------------------------------------------------------------------------------------------------------
074
075   /**
076    * Mutable header fields; mirrors {@code $defs/header}.
077    *
078    * <h5 class='section'>Example:</h5>
079    * <p class='bjava'>
080    *    <jv>page</jv>.header(<jv>h</jv> -&gt; <jv>h</jv>.title(<js>"My App"</js>).logo(<js>"/img/logo.svg"</js>, <js>"/"</js>, <js>"My App"</js>).chrome(<jk>true</jk>));
081    * </p>
082    */
083   public static final class HeaderSpec {
084      String title;
085      String subtitle;
086      String logoSrc;
087      String logoHref;
088      String logoAlt;
089      boolean chrome;
090      final List<Map<String,Object>> links = new ArrayList<>();
091      Map<String,Object> userMenu;
092
093      /** @param v The header title. @return This object. */
094      public HeaderSpec title(String v) { title = v; return this; }
095      /** @param v The subtitle. @return This object. */
096      public HeaderSpec subtitle(String v) { subtitle = v; return this; }
097      /** @param src Logo URL. @param href Link target, or <jk>null</jk>. @param alt Alt text, or <jk>null</jk>. @return This object. */
098      public HeaderSpec logo(String src, String href, String alt) { logoSrc = src; logoHref = href; logoAlt = alt; return this; }
099      /** @param v Whether the header and nav share the sticky {@code .jc-chrome} wrapper. @return This object. */
100      public HeaderSpec chrome(boolean v) { chrome = v; return this; }
101      /** @param label Link label. @param href Link target. @return This object. */
102      public HeaderSpec link(String label, String href) { links.add(link0(label, href)); return this; }
103      /** @param v A {@code $defs/userMenu} map. @return This object. */
104      public HeaderSpec userMenu(Map<String,Object> v) { userMenu = v; return this; }
105   }
106
107   /**
108    * Mutable footer fields; mirrors {@code $defs/footer}.
109    *
110    * <h5 class='section'>Example:</h5>
111    * <p class='bjava'>
112    *    <jv>page</jv>.footer(<jv>f</jv> -&gt; <jv>f</jv>.text(<js>"Sandbox Support Console"</js>).link(<js>"Docs"</js>, <js>"/docs"</js>));
113    * </p>
114    */
115   public static final class FooterSpec {
116      String text;
117      final List<Map<String,Object>> links = new ArrayList<>();
118
119      /** @param v Trusted, author-supplied HTML rendered as-is by the console shell (entities decode, markup becomes real elements); must be a literal template/config value, never request data. @return This object. */
120      public FooterSpec text(String v) { text = v; return this; }
121      /** @param label Link label. @param href Link target. @return This object. */
122      public FooterSpec link(String label, String href) { links.add(link0(label, href)); return this; }
123   }
124
125   private static Map<String,Object> link0(String label, String href) {
126      var m = new LinkedHashMap<String,Object>();
127      m.put("label", label);
128      m.put("href", href);
129      return m;
130   }
131
132   //-----------------------------------------------------------------------------------------------------------------
133   // State
134   //-----------------------------------------------------------------------------------------------------------------
135
136   private String title = "";
137   private String theme;
138   private boolean themeFromDirective;
139   private String scheme;
140   private boolean schemeToggle;
141   private String tab = "";
142   private String navLayout;
143   private String shellUrl;
144   private final HeaderSpec header = new HeaderSpec();
145   private final FooterSpec footer = new FooterSpec();
146   private final NavNode navRoot = NavNode.root();
147   private List<String> selected;
148   private List<String> activeNav = List.of();
149   private final List<CardSpec> cards = new ArrayList<>();
150   private final Map<String,Map<String,Object>> topics = new LinkedHashMap<>();
151   private final Map<String,String> topicWhere = new HashMap<>();
152   private final Map<String,Map<String,Object>> bridges = new LinkedHashMap<>();
153   /** The {@code dashboard} contract object ({@code PageSpec.dashboard}), or <jk>null</jk>. */
154   private Map<String,Object> dashboard;
155   private final Map<String,Map<String,Object>> dataSources = new LinkedHashMap<>();
156   private final Map<String,BadgeDef> badges = new LinkedHashMap<>();
157   private Map<String,Object> javaFacts = Map.of();
158   private Map<String,Object> chromeFacts = Map.of();
159   private Map<String,Object> facts = Map.of();
160   // Cards from a PageSpec, held until placed or the page closes.  builtCards: not yet placed, in declaration order.
161   // builtCardIds: every id ever registered here, kept after placement.
162   private final Map<String,CardSpec> builtCards = new LinkedHashMap<>();
163   private final Set<String> builtCardIds = new LinkedHashSet<>();
164   private final Map<String,String> templates = new LinkedHashMap<>();
165   private final Map<String,Map<String,String>> slots = new LinkedHashMap<>();
166   private List<String> toolkits = List.of();
167   private List<String> init = List.of();
168   private List<String> css = List.of();
169   private boolean assetsResolved;
170   private List<String> vendorCss = List.of();
171   private List<String> vendorJs = List.of();
172   private List<String> runtimeCss = List.of();
173   private List<String> runtimeJs = List.of();
174   private final Set<String> requiredPacks = new LinkedHashSet<>();
175   private int segments;
176   private int mainCount;
177   private int autoCards;
178   // Stable writers: <@page>/<@console> render their bodies straight into these, so flushSegment()/markMain()
179   // reset them in place instead of swapping in new instances.
180   private final StringWriter pageBuffer = new StringWriter();
181   private final StringWriter consoleBuffer = new StringWriter();
182
183   // Package-private server-side slots (not in the contract).
184   String head;
185   String headBeforePageCss;
186   String scripts;
187   String scriptsAfterToolkit;
188   String bodyAttrs;
189   String ftlBodyAttrs;
190   String themeCssUrl;
191   /** The validated {@code <@console theme=>} value, or <jk>null</jk>; the seed for a nameless {@code <@theme>}. */
192   String consoleTheme;
193   String themeOverrideBlock;
194   ThemeBuildContext themeBuild;
195   final Deque<NavNode> navCursor = new ArrayDeque<>();
196   boolean navOpen;
197   boolean consoleOpen;
198   boolean pageOpen;
199   boolean consoleDone;
200   boolean cardOpen;
201   boolean underRootOpen;
202   boolean slotOpen;
203   String underRootParent = "";
204   NavNode underRootSelected;
205
206   /**
207    * A nav-tree mutation deferred until the chrome's nav tree exists.  Unlike a plain {@link Consumer}, it may
208    * throw {@link TemplateModelException}, which {@link NavNode#add(String, String, String)} does on a duplicate
209    * sibling id.
210    */
211   @FunctionalInterface
212   public interface NavAdder {
213      /**
214       * @param parent The resolved ancestor node.
215       * @throws TemplateModelException If adding under {@code parent} fails.
216       */
217      void accept(NavNode parent) throws TemplateModelException;
218   }
219
220   /** One pending nav addition: a child to add under an existing chrome node, resolved at {@code </@console>}
221    * because the chrome's own nav tree may not exist yet when the addition is recorded. */
222   record PendingNavAdd(String parentIdPath, NavAdder adder) {}
223
224   final List<PendingNavAdd> pendingNavAdds = new ArrayList<>();
225
226   PageCapture() {}
227
228   /**
229    * Returns the capture for this render, creating it on first use.
230    *
231    * <p>
232    * On create, a {@link PageSpec} carried in the data model under {@link PageSpec#ATTR} is applied to the new
233    * capture before any directive writes to it, and marked adopted.
234    *
235    * @param env The FreeMarker environment.
236    * @return The capture, never <jk>null</jk>.
237    * @throws TemplateModelException If seeding a {@link PageSpec} from the data model fails.
238    */
239   public static PageCapture of(Environment env) throws TemplateModelException {
240      var c = (PageCapture)env.getCustomState(KEY);
241      if (n(c)) {
242         c = new PageCapture();
243         var tm = env.getDataModel().get(PageSpec.ATTR);
244         if (nn(tm) && DeepUnwrap.unwrap(tm) instanceof PageSpec.Seed seed) {
245            var cardModel = env.getConfiguration().getSharedVariable(CardDirectiveModel.NAME);
246            seed.adoptInto(c, cardModel instanceof CardDirectiveModel m ? m : null);
247         }
248         env.setCustomState(KEY, c);  // Only after a successful seed, so a failed one leaves nothing half-applied.
249      }
250      return c;
251   }
252
253   /**
254    * Returns the capture for this render, if one exists.
255    *
256    * @param env The FreeMarker environment.
257    * @return The capture, or <jk>null</jk>.
258    */
259   public static PageCapture get(Environment env) {
260      return (PageCapture)env.getCustomState(KEY);
261   }
262
263   /**
264    * Renders a directive body to a string.
265    *
266    * @param body The body, or <jk>null</jk>.
267    * @return The rendered markup, or {@code ""} when the body is <jk>null</jk>.
268    * @throws TemplateException On a template error.
269    * @throws IOException On an I/O error.
270    */
271   public static String render(TemplateDirectiveBody body) throws TemplateException, IOException {
272      if (n(body))
273         return "";
274      var sw = new StringWriter();
275      body.render(sw);
276      return sw.toString();
277   }
278
279   //-----------------------------------------------------------------------------------------------------------------
280   // Setters
281   //-----------------------------------------------------------------------------------------------------------------
282
283   /** @param v The page title. @return This object. */
284   public PageCapture title(String v) { title = v; return this; }
285
286   /**
287    * Sets the title only when none has been set yet.  A page-spec title always wins over the chrome's own
288    * {@code <@console title>} or {@code brand=} default; this is the one deliberate silent override in the merge rules.
289    *
290    * @param fallback The chrome-resolved default.  A <jk>null</jk> is treated as blank.
291    * @return This object.
292    */
293   PageCapture titleDefault(String fallback) {
294      if (title.isEmpty())
295         title = fallback == null ? "" : fallback;
296      return this;
297   }
298
299   /** @param name A stock theme name. @return This object. */
300   public PageCapture theme(String name) { theme = name; return this; }
301
302   /** @param name A colour-scheme pin: {@code auto}, {@code light} or {@code dark}. @return This object. */
303   public PageCapture scheme(String name) { scheme = name; return this; }
304
305   /** @param value Whether the visitor's stored scheme choice is honoured. @return This object. */
306   public PageCapture schemeToggle(boolean value) { schemeToggle = value; return this; }
307
308   /** @param v The {@code <@page tab>} value. @return This object. */
309   public PageCapture tab(String v) { tab = n(v) ? "" : v; return this; }
310
311   /** @param names Toolkit names from {@code <@page toolkit>}. @return This object. */
312   public PageCapture toolkit(List<String> names) { toolkits = List.copyOf(names); return this; }
313
314   /** @param c Mutates the header. @return This object. */
315   public PageCapture header(Consumer<HeaderSpec> c) { c.accept(header); return this; }
316
317   /** @param c Mutates the footer. @return This object. */
318   public PageCapture footer(Consumer<FooterSpec> c) { c.accept(footer); return this; }
319
320   /** @param horizontalOrVertical The nav layout. @return This object. */
321   public PageCapture navLayout(String horizontalOrVertical) { navLayout = horizontalOrVertical; return this; }
322
323   /** @return The synthetic nav root; its children are the contract's {@code nav[]}. */
324   public NavNode navRoot() { return navRoot; }
325
326   /**
327    * Explicitly selects a nav path ({@code <@node selected="true">}).
328    *
329    * @param idPath The root-first id path.
330    * @return This object.
331    * @throws TemplateModelException If a path is already selected (E-6).
332    */
333   public PageCapture select(List<String> idPath) throws TemplateModelException {
334      if (nn(selected))
335         throw FtlAttrLists.reject(f("<@node id='%s'> selected=true but '%s' is already selected.",
336            idPath.get(idPath.size() - 1), String.join("/", selected)));
337      selected = List.copyOf(idPath);
338      return this;
339   }
340
341   /**
342    * Appends a card.
343    *
344    * @param card The card.
345    * @param markupOrNull Markup emitted as {@code <template data-card="{id}">}, or <jk>null</jk>.
346    * @return This object.
347    * @throws TemplateModelException On a duplicate card id (E-9).
348    */
349   public PageCapture addCard(CardSpec card, String markupOrNull) throws TemplateModelException {
350      checkSrc(card);
351      for (var c : cards)
352         if (c.id().equals(card.id()))
353            throw FtlAttrLists.reject(f("<@card id='%s'> duplicates an existing card id.", card.id()));
354      if (builtCardIds.contains(card.id()))
355         throw FtlAttrLists.reject(f("<@card id='%s'> duplicates an existing card id.", card.id()));
356      if (nn(markupOrNull)) {
357         card.template(card.id());
358         templates.put(card.id(), markupOrNull);
359      }
360      cards.add(card);
361      return this;
362   }
363
364   // Every card reaching the page passes through here: <@card> html cards, PageSpec.html / PageSpec.card, and direct
365   // addCard callers.  Checks, in order: (1) a card's src is fetched by the browser, so it must be a same-origin path
366   // ({token} placeholders allowed); (2) sanitize= is valid only on an html card with a src.  The src check wins when both fail.
367   private static void checkSrc(CardSpec card) throws TemplateModelException {
368      var src = card.src();
369      if (nn(src) && ! SafePathTemplate.isSameOriginPath(src))
370         throw FtlAttrLists.reject(f("<@card id='%s'> type='%s' src must be a same-origin path (no scheme, '//', backslash, control character, edge space or '..' segment); got '%s'.",
371            card.id(), card.type(), src));
372      if (card.sanitize() && (neq(card.type(), "html") || ! nn(src)))
373         throw FtlAttrLists.reject(f("<@card id='%s'> sanitize= applies only to an html card with src=.", card.id()));
374   }
375
376   /**
377    * Registers a card built by a {@code PageSpec}.  It is held apart from the authored cards until placed or the
378    * page closes, so a spec's cards follow the template's cards in declaration order.
379    *
380    * @param card The card.
381    * @param markupOrNull Markup emitted as {@code <template data-card="{id}">}, or <jk>null</jk>.
382    * @throws TemplateModelException On a duplicate card id, checked against placed and unplaced cards.
383    */
384   void addBuiltCard(CardSpec card, String markupOrNull) throws TemplateModelException {
385      checkSrc(card);
386      for (var c : cards)
387         if (c.id().equals(card.id()))
388            throw FtlAttrLists.reject(f("<@card id='%s'> duplicates an existing card id.", card.id()));
389      if (builtCards.containsKey(card.id()) || builtCardIds.contains(card.id()))
390         throw FtlAttrLists.reject(f("<@card id='%s'> duplicates an existing card id.", card.id()));
391      if (nn(markupOrNull)) {
392         card.template(card.id());
393         templates.put(card.id(), markupOrNull);
394      }
395      builtCards.put(card.id(), card);
396      builtCardIds.add(card.id());
397   }
398
399   /**
400    * Places a built card at the current position in the card order.
401    *
402    * @param id The built card id.
403    * @throws TemplateModelException If the card was already placed, or no built card has that id.
404    */
405   void placeBuiltCard(String id) throws TemplateModelException {
406      var card = builtCards.remove(id);
407      if (n(card)) {
408         if (builtCardIds.contains(id))
409            throw FtlAttrLists.reject(f("<@card ref='%s'> places a PageSpec card that was already placed.", id));
410         throw FtlAttrLists.reject(f("<@card ref='%s'> names no PageSpec card; declared: '%s'.", id,
411            String.join(", ", builtCardIds)));
412      }
413      cards.add(card);
414   }
415
416   /** Appends every still-unplaced built card after the authored and placed cards, in declaration order. */
417   void flushUnplacedBuiltCards() {
418      cards.addAll(builtCards.values());
419      builtCards.clear();
420   }
421
422   /**
423    * Adds a header or footer slot template.
424    *
425    * @param scope {@code header} or {@code footer}.
426    * @param name The slot name.
427    * @param markup The slot markup.
428    * @return This object.
429    */
430   public PageCapture addSlot(String scope, String name, String markup) {
431      var id = scope + "." + name;
432      slots.computeIfAbsent(scope, k -> new LinkedHashMap<>()).put(name, id);
433      templates.put(id, markup);
434      return this;
435   }
436
437   /**
438    * Adds a top-level {@code topics} entry.  Java ({@code PageSpec.topic}) and FTL ({@code <@topic>}) declarations
439    * union; the same topic again with the same {@code retain} is a no-op.
440    *
441    * @param decl A {@code TopicDecl.toMap()} entry.
442    * @param where Where it was declared, for the E-46 message, for example {@code "<@topic>"}.
443    * @return This object.
444    * @throws IllegalArgumentException E-46 if the topic is already declared with a different {@code retain}.
445    */
446   PageCapture addTopic(Map<String,Object> decl, String where) {
447      var topic = (String)decl.get("topic");
448      var prior = topics.get(topic);
449      if (prior == null) {
450         topics.put(topic, decl);
451         topicWhere.put(topic, where);
452      } else if (neq(prior.get("retain"), decl.get("retain"))) {
453         throw iaex("topic '%s' is declared with retain=%s here and retain=%s at %s",
454            topic, decl.get("retain"), prior.get("retain"), topicWhere.get(topic));
455      } else if (neq(prior.get("publisher"), decl.get("publisher"))) {
456         throw iaex("topic '%s' is declared with publisher=%s here and publisher=%s at %s",
457            topic, decl.get("publisher"), prior.get("publisher"), topicWhere.get(topic));
458      }
459      return this;
460   }
461
462   /**
463    * Adds a top-level {@code bridges} entry, with {@code session} already resolved.
464    *
465    * @param bridge A {@code BridgeDecl.toMap()} entry.
466    * @return This object.
467    * @throws IllegalArgumentException E-51 on a duplicate bridge id.
468    */
469   PageCapture addBridge(Map<String,Object> bridge) {
470      var id = (String)bridge.get("id");
471      if (bridges.containsKey(id))
472         throw iaex("bridge '%s': %s", id, "duplicate id");
473      bridges.put(id, bridge);
474      return this;
475   }
476
477   /**
478    * Adds a top-level {@code dataSources} entry.
479    *
480    * @param source A {@code DataSourceDef.toMap()} or {@code Derived.toMap()} entry.
481    * @return This object.
482    * @throws IllegalArgumentException E-76 on a duplicate data source id.
483    */
484   PageCapture addDataSource(Map<String,Object> source) {
485      var id = (String)source.get("id");
486      if (dataSources.containsKey(id))
487         throw iaex(DataSourceDef.E76, id, "duplicate id");
488      dataSources.put(id, source);
489      return this;
490   }
491
492   /**
493    * Runs R-10 and R-11 over the captured page; called at {@code </@console>} and by {@code PageSpec.view}.
494    *
495    * @param registry The card types the page may use: the {@code ConsoleFreemarkerMixin}'s registry, so custom
496    *    {@code Builder.cardType(...)} handlers are seen.
497    * @throws IllegalArgumentException With the first problem's message.
498    */
499   void checkWiring(CardTypeRegistry registry) {
500      List<BusWiringValidator.Problem> problems;
501      try {
502         problems = BusWiringValidator.validate(JsonMap.ofString(toContractJson()), registry);
503      } catch (Exception e) {
504         throw new IllegalStateException(e);
505      }
506      if (! problems.isEmpty())
507         throw new IllegalArgumentException(problems.get(0).message());
508   }
509
510   /**
511    * Appends a header badge.
512    *
513    * @param badge The badge.  Must not be <jk>null</jk>.
514    * @return This object.
515    * @throws TemplateModelException On a duplicate badge id.
516    */
517   public PageCapture addBadge(BadgeDef badge) throws TemplateModelException {
518      var id = (String)badge.toContractMap().get("id");
519      if (badges.containsKey(id))
520         throw FtlAttrLists.reject(f("Badge id '%s' is declared twice (PageSpec and/or <@badge>).", id));
521      badges.put(id, badge);
522      return this;
523   }
524
525   /**
526    * Non-strict, last-wins merge: same-side repeats of facts, and the base of the strict Java-vs-chrome merge.
527    *
528    * @param base The base map.
529    * @param overlay The overlay; its values win.
530    * @return A new merged map.
531    */
532   static Map<String,Object> mergeFactsLastWins(Map<String,Object> base, Map<String,Object> overlay) {
533      var out = new LinkedHashMap<String,Object>(base);
534      out.putAll(overlay);
535      return out;
536   }
537
538   /**
539    * Sets the page's dashboard.
540    *
541    * @param v The {@code dashboard} contract object built by {@code DashboardSpec.toJson}.
542    * @throws IllegalArgumentException E-77 if the page already has one.
543    */
544   void dashboard(Map<String,Object> v) {
545      if (dashboard != null)
546         throw new IllegalArgumentException("E-77: " + f(BusWiringValidator.E77, v.get("id"), "a page has at most one dashboard"));
547      dashboard = v;
548   }
549
550   /** @param v Facts seeded from a {@code PageSpec}; merged last-wins with earlier seeds. */
551   void seedJavaFacts(Map<String,Object> v) {
552      javaFacts = mergeFactsLastWins(javaFacts, v);
553   }
554
555   /** @param v Facts from {@code <@console facts=>} or {@code <@facts>}; merged last-wins with earlier ones. @return This object. */
556   PageCapture mergeChromeFacts(Map<String,Object> v) throws TemplateModelException {
557      try {
558         PageSpec.checkFactTypes("", v);
559      } catch (IllegalArgumentException e) {
560         throw FtlAttrLists.reject(e.getMessage());
561      }
562      chromeFacts = mergeFactsLastWins(chromeFacts, v);
563      return this;
564   }
565
566   /**
567    * {@code </@console>}: strictly deep-merges the Java and chrome facts, naming the dotted path of any leaf set by both.
568    *
569    * @throws TemplateModelException On a leaf set by both sides.
570    */
571   void resolveFacts() throws TemplateModelException {
572      facts = deepMergeFacts("", javaFacts, chromeFacts);
573   }
574
575   @SuppressWarnings({
576      "unchecked" // Narrows a JSON-shaped Map<?,?> to Map<String,Object>; its keys are always strings.
577   })
578   private static Map<String,Object> deepMergeFacts(String prefix, Map<String,Object> a, Map<String,Object> b) throws TemplateModelException {
579      var out = new LinkedHashMap<String,Object>(a);
580      for (var e : b.entrySet()) {
581         var path = prefix.isEmpty() ? e.getKey() : prefix + "." + e.getKey();
582         if (! out.containsKey(e.getKey())) {
583            out.put(e.getKey(), e.getValue());
584            continue;
585         }
586         var existing = out.get(e.getKey());
587         if (existing instanceof Map<?,?> && e.getValue() instanceof Map<?,?>)
588            out.put(e.getKey(), deepMergeFacts(path, (Map<String,Object>)existing, (Map<String,Object>)e.getValue()));
589         else
590            throw FtlAttrLists.reject(f("Fact '%s' is set by both PageSpec.facts and the chrome; set it in one place.", path));
591      }
592      return out;
593   }
594
595   /**
596    * {@code </@console>}: checks each badge against the finished page.  {@code table} and every {@code refreshes}
597    * entry (except those also in {@code refreshesAllowMissing}) must name a card on the page; when {@code scope.by} is
598    * {@code nav}, each {@code scope.values} key must name a node in the resolved nav tree.
599    *
600    * @throws TemplateModelException On an unknown card id or nav node.
601    */
602   @SuppressWarnings({
603      "unchecked" // Narrows a JSON-shaped Map<?,?> to Map<String,Object>; its keys are always strings.
604   })
605   void checkBadges() throws TemplateModelException {
606      var cardIds = new HashSet<String>(builtCardIds);
607      for (var c : cards)
608         cardIds.add(c.id());
609      var navIds = new HashSet<String>();
610      collectNavIds(navRoot, navIds);
611      for (var b : badges.values()) {
612         var m = b.toContractMap();
613         var id = (String)m.get("id");
614         for (var card : badgeCardRefs(m))
615            if (! cardIds.contains(card))
616               throw FtlAttrLists.reject(f(BADGE_CARD_MSG, id, card));
617         if (m.get("scope") instanceof Map<?,?> scope && "nav".equals(scope.get("by")) && scope.get("values") instanceof Map<?,?> values)
618            for (var key : ((Map<String,Object>)values).keySet())
619               if (! navIds.contains(key))
620                  throw FtlAttrLists.reject(f(BADGE_NAV_MSG, id, key));
621      }
622   }
623
624   static final String BADGE_CARD_MSG = "<@badge id='%s'> references card '%s', which is not on the page.";
625   static final String BADGE_NAV_MSG = "<@badge id='%s'> scope names nav node '%s', which is not in the nav tree.";
626
627   /**
628    * @param badge A badge's contract map.
629    * @return Its {@code table} followed by its {@code refreshes} entries, minus those listed in
630    *    {@code refreshesAllowMissing}.
631    */
632   static List<String> badgeCardRefs(Map<String,Object> badge) {
633      var out = new ArrayList<String>();
634      if (badge.get("table") instanceof String t)
635         out.add(t);
636      var optional = badge.get("refreshesAllowMissing") instanceof List<?> a ? a : List.of();
637      if (badge.get("refreshes") instanceof List<?> r)
638         for (var e : r)
639            if (! optional.contains(e))
640               out.add(String.valueOf(e));
641      return out;
642   }
643
644   private static void collectNavIds(NavNode node, Set<String> out) {
645      if (nn(node.id()))
646         out.add(node.id());
647      for (var c : node.children())
648         collectNavIds(c, out);
649   }
650
651   /**
652    * Records a child to add under an existing chrome nav node, resolved later by {@link #applyPendingNavAdds()}.
653    *
654    * @param parentIdPath The root-first id path to the existing ancestor node.
655    * @param adder Receives the resolved ancestor and adds its children to it.
656    */
657   public void addPendingNavUnder(String parentIdPath, NavAdder adder) {
658      pendingNavAdds.add(new PendingNavAdd(parentIdPath, adder));
659   }
660
661   /**
662    * Resolves every pending nav addition recorded by {@link #addPendingNavUnder}, in declaration order.  The
663    * pending list is cleared first, so a second call does nothing.
664    *
665    * @throws TemplateModelException If a {@code parentIdPath} does not match an existing node.
666    */
667   public void applyPendingNavAdds() throws TemplateModelException {
668      var batch = List.copyOf(pendingNavAdds);
669      pendingNavAdds.clear();
670      for (var p : batch) {
671         var parent = navRoot.find(List.of(p.parentIdPath().split("/")));
672         if (parent.isEmpty())
673            throw FtlAttrLists.reject(f(
674               "Nav addition under '%s' does not match a <@node> path in the chrome; known paths: '%s'.",
675               p.parentIdPath(), String.join(", ", navRoot.paths())));
676         p.adder().accept(parent.get());
677      }
678   }
679
680   /**
681    * Merge-aware twin of {@link #tab(String)}: first writer wins, and any second non-blank writer is rejected, even with the
682    * same value.
683    *
684    * @param tab The {@code <@page tab>} value.  Blank is a no-op.
685    * @return This object.
686    * @throws TemplateModelException If a non-blank tab was already set.
687    */
688   public PageCapture mergeTab(String tab) throws TemplateModelException {
689      if (ib(tab))
690         return this;
691      if (this.tab.isEmpty()) {
692         this.tab = tab;
693         return this;
694      }
695      throw FtlAttrLists.reject(f(
696         "PageSpec sets tab='%s' and <@page tab='%s'> also sets it; set it in one place.", this.tab, tab));
697   }
698
699   /**
700    * Merge-aware twin of {@link #theme(String)}: first writer wins, a repeat of the same value is a no-op, and a
701    * differing second writer is rejected.
702    *
703    * @param name The {@code <@theme name>} value.  Blank is a no-op.
704    * @return This object.
705    * @throws TemplateModelException If a different, non-blank theme was already set.
706    */
707   public PageCapture mergeTheme(String name) throws TemplateModelException {
708      if (ib(name))
709         return this;
710      if (ib(theme)) {
711         theme = name;
712         themeFromDirective = true;
713         return this;
714      }
715      if (theme.equals(name))
716         return this;
717      if (themeFromDirective)
718         throw FtlAttrLists.reject(f(
719            "<@theme name='%s'> conflicts with an earlier <@theme name='%s'> on this page; use one.", name, theme));
720      throw FtlAttrLists.reject(f(
721         "PageSpec theme '%s' conflicts with <@theme name='%s'>; remove name= from <@theme> to inherit the "
722            + "page theme.", theme, name));
723   }
724
725   /** @return The current theme name, or <jk>null</jk> if neither a spec nor a directive has set one yet. */
726   String themeOrNull() { return theme; }
727
728   /**
729    * Merge-aware setter for the raw {@code <@body>} attribute text: union by attribute name, existing (spec) text
730    * first and then the new text.  The same attribute name on both sides is always rejected, even when the values
731    * agree.  Called once, from {@link #checkConsoleClose()}, with the text {@code <@body>} captured into
732    * {@link #ftlBodyAttrs}.
733    *
734    * @param attrs The raw {@code <@body>} attribute text.  Blank is a no-op.
735    * @return This object.
736    * @throws TemplateModelException If an attribute name already set is set again.
737    */
738   public PageCapture mergeBodyAttrs(String attrs) throws TemplateModelException {
739      if (ib(attrs))
740         return this;
741      if (ib(bodyAttrs)) {
742         bodyAttrs = attrs;
743         return this;
744      }
745      var existing = bodyAttrNames(bodyAttrs);
746      for (var name : bodyAttrNames(attrs))
747         if (existing.contains(name))
748            throw FtlAttrLists.reject(f(
749               "PageSpec body attribute '%s' is also set by <@body>; set it in one place.", name));
750      bodyAttrs = (bodyAttrs + " " + attrs).strip();
751      return this;
752   }
753
754   private static final Pattern BODY_ATTR_NAME = Pattern.compile("[A-Za-z_:][-A-Za-z0-9_:.]*");
755
756   /** One attribute found by {@link #tokenizeBodyAttrs(String)}: its span in the text, its name, and whether it has a value. */
757   private record BodyAttr(int start, int end, String name, boolean hasValue) {}
758
759   // <@body> markup is raw, so a value can be double-quoted, single-quoted or unquoted.  Tokenizing (rather than
760   // regex-searching the whole text) means a name is only ever read where an attribute can start, never from inside
761   // another attribute's quoted value, e.g. title="data-juneau-csrf=x".
762   private static List<BodyAttr> tokenizeBodyAttrs(String text) {
763      var out = new ArrayList<BodyAttr>();
764      var n = text.length();
765      var i = 0;
766      while (i < n) {
767         if (Character.isWhitespace(text.charAt(i))) {
768            i++;
769            continue;
770         }
771         var start = i;
772         while (i < n && ! Character.isWhitespace(text.charAt(i)) && text.charAt(i) != '=')
773            i++;
774         var name = text.substring(start, i);
775         var j = i;
776         while (j < n && Character.isWhitespace(text.charAt(j)))
777            j++;
778         var hasValue = false;
779         if (j < n && text.charAt(j) == '=') {
780            j++;
781            while (j < n && Character.isWhitespace(text.charAt(j)))
782               j++;
783            if (j < n && (text.charAt(j) == '"' || text.charAt(j) == '\'')) {
784               var close = text.indexOf(text.charAt(j), j + 1);
785               j = close < 0 ? n : close + 1;
786            } else {
787               while (j < n && ! Character.isWhitespace(text.charAt(j)))
788                  j++;
789            }
790            hasValue = true;
791            i = j;
792         } else if (i == start) {
793            i++;  // A stray '=': step over it so the scan always advances.
794         }
795         if (BODY_ATTR_NAME.matcher(name).matches())
796            out.add(new BodyAttr(start, i, name, hasValue));
797      }
798      return out;
799   }
800
801   private static Set<String> bodyAttrNames(String text) {
802      return tokenizeBodyAttrs(text).stream().filter(BodyAttr::hasValue).map(BodyAttr::name)
803         .collect(Collectors.toCollection(() -> new TreeSet<>(String.CASE_INSENSITIVE_ORDER)));
804   }
805
806   /**
807    * Whether the merged body-attribute text already sets {@code name}.  Used so a spec- or {@code <@body>}-authored
808    * CSRF attribute is not emitted a second time by the request-attribute auto-detection.
809    *
810    * @param name An HTML attribute name.
811    * @return Whether the merged text sets it.
812    */
813   boolean hasBodyAttr(String name) {
814      return bodyAttrs != null && tokenizeBodyAttrs(bodyAttrs).stream().anyMatch(a -> a.hasValue() && a.name().equalsIgnoreCase(name));
815   }
816
817   /**
818    * The merged body-attribute text with the named attributes taken out, for a response that must not carry them.
819    *
820    * @param names The HTML attribute names to drop.
821    * @return The remaining text, possibly blank, or <jk>null</jk> if no body attributes were set.
822    */
823   String bodyAttrsWithout(String...names) {
824      if (bodyAttrs == null)
825         return null;
826      var s = new StringBuilder(bodyAttrs);
827      var attrs = tokenizeBodyAttrs(bodyAttrs);
828      for (var k = attrs.size() - 1; k >= 0; k--) {
829         var a = attrs.get(k);
830         if (! a.hasValue() || Arrays.stream(names).noneMatch(a.name()::equalsIgnoreCase))
831            continue;
832         var from = a.start();
833         while (from > 0 && Character.isWhitespace(s.charAt(from - 1)))
834            from--;
835         s.delete(from, a.end());
836      }
837      return s.toString().strip();
838   }
839
840   /**
841    * Union merge for {@link #toolkit(List)}: appends any name not already present, in first-seen order.
842    *
843    * @param names The names to union in.
844    * @return This object.
845    */
846   public PageCapture mergeToolkit(List<String> names) {
847      toolkits = mergeUnion(toolkits, names);
848      return this;
849   }
850
851   /**
852    * Union merge for the page stylesheets: appends any URL not already present, in first-seen order.
853    *
854    * @param hrefs The URLs to union in.
855    * @return This object.
856    */
857   public PageCapture mergeCss(List<String> hrefs) {
858      css = mergeUnion(css, hrefs);
859      return this;
860   }
861
862   /**
863    * Union merge for the page init scripts: appends any URL not already present, in first-seen order.
864    *
865    * @param srcs The URLs to union in.
866    * @return This object.
867    */
868   public PageCapture mergeInit(List<String> srcs) {
869      init = mergeUnion(init, srcs);
870      return this;
871   }
872
873   // Dedupes within a single list too, not only across the two sources: css=["a.css","a.css"] yields one link.
874   private static List<String> mergeUnion(List<String> existing, List<String> additions) {
875      var out = tl(existing);
876      for (var a : additions)
877         if (! out.contains(a))
878            out.add(a);
879      return List.copyOf(out);
880   }
881
882   void css(List<String> v) { css = List.copyOf(v); }
883   void init(List<String> v) { init = List.copyOf(v); }
884   void toolkitAssets(ToolkitPackRegistry.Resolved r) {
885      assetsResolved = true;
886      vendorCss = r.vendorCss();
887      vendorJs = r.vendorJs();
888      runtimeCss = r.runtimeCss();
889      runtimeJs = r.runtimeJs();
890   }
891   void shellUrl(String v) { shellUrl = v; }
892   boolean hasBadges() { return ! badges.isEmpty(); }
893   StringWriter pageBuffer() { return pageBuffer; }
894   StringWriter consoleBuffer() { return consoleBuffer; }
895   List<String> cssHrefs() { return css; }
896   List<String> initScripts() { return init; }
897   boolean assetsResolved() { return assetsResolved; }
898   List<String> vendorCss() { return vendorCss; }
899   List<String> vendorJs() { return vendorJs; }
900   List<String> runtimeCss() { return runtimeCss; }
901   List<String> runtimeJs() { return runtimeJs; }
902
903   /**
904    * Records toolkit packs the page needs, in first-seen order.  Called by {@code <@card>} for each authored card
905    * and by other directives that emit markup needing a pack.
906    *
907    * @param packs The pack names.
908    * @return This object.
909    */
910   public PageCapture require(Collection<String> packs) {
911      requiredPacks.addAll(packs);
912      return this;
913   }
914
915   /** @return The recorded pack names, in first-seen order. */
916   public Set<String> requiredPacks() { return u(requiredPacks); }
917
918   /** @return <jk>true</jk> while a {@code <@page>} body is rendering. */
919   public boolean inPage() { return pageOpen; }
920   String title() { return title; }
921   List<CardSpec> unplacedCards() { return List.copyOf(builtCards.values()); }
922   String theme() { return theme; }
923   String scheme() { return scheme; }
924   boolean schemeToggle() { return schemeToggle; }
925   String headerSubtitle() { return header.subtitle; }
926   Map<String,Object> headerUserMenu() { return header.userMenu; }
927   boolean hasHeaderTitle() { return nn(header.title); }
928
929   //-----------------------------------------------------------------------------------------------------------------
930   // Lifecycle hooks used by the directives
931   //-----------------------------------------------------------------------------------------------------------------
932
933   /** Turns pending non-blank {@code <@page>} body markup into a bare {@code jc-seg-N} card. */
934   void flushSegment() throws TemplateModelException {
935      var s = pageBuffer.toString();
936      pageBuffer.getBuffer().setLength(0);
937      if (s.isBlank())
938         return;
939      var id = "jc-seg-" + (++segments);
940      addCard(CardSpec.html(id).bare(true), s);
941   }
942
943   /** The next {@code jc-card-N} id for a {@code <@card>} without {@code id=}. */
944   String nextCardId() {
945      return "jc-card-" + (++autoCards);
946   }
947
948   /** {@code <@main/>}: the console buffer so far is the banner. */
949   void markMain() {
950      mainCount++;
951      var s = consoleBuffer.toString();
952      consoleBuffer.getBuffer().setLength(0);
953      if (! s.isBlank())
954         addSlot("header", "banner", s);
955   }
956
957   /** {@code </@console>}: E-13 then E-12, then merge the {@code <@body>} attributes with any spec-supplied ones, then
958    * resolve pending nav additions (E-B8), all before the caller's own {@code resolveActiveNav()}. */
959   void checkConsoleClose() throws TemplateModelException {
960      if (mainCount != 1)
961         throw FtlAttrLists.reject(f("<@console> requires exactly one <@main/>; found '%s'.", mainCount));
962      if (! consoleBuffer.toString().isBlank())
963         throw FtlAttrLists.reject("<@console> has markup after <@main/>; move it into <@footer> or <@scripts>.");
964      if (ftlBodyAttrs != null)
965         mergeBodyAttrs(ftlBodyAttrs);
966      applyPendingNavAdds();
967      flushUnplacedBuiltCards();
968      resolveFacts();
969      checkBadges();
970   }
971
972   /** Explicit selection, else tab (E-7 when it does not resolve), else empty. */
973   void resolveActiveNav() throws TemplateModelException {
974      if (nn(selected)) {
975         activeNav = selected;
976         return;
977      }
978      if (tab.isEmpty()) {
979         activeNav = List.of();
980         return;
981      }
982      var path = List.of(tab.split("/"));
983      if (navRoot.find(path).isEmpty())
984         throw FtlAttrLists.reject(f("<@page tab='%s'> does not match a visible <@node> path; known paths: '%s'.",
985            tab, String.join(", ", navRoot.paths())));
986      activeNav = path;
987   }
988
989   //-----------------------------------------------------------------------------------------------------------------
990   // Getters / output
991   //-----------------------------------------------------------------------------------------------------------------
992
993   /** @return The {@code <@page tab>} value, {@code ""} when absent. */
994   public String tab() { return tab; }
995
996   /** @return The toolkit names. */
997   public List<String> toolkits() { return toolkits; }
998
999   /** @return The resolved active path (empty until {@code <@console>} closes, or when there is no selection). */
1000   public List<String> activeNav() { return activeNav; }
1001
1002   /** @return The cards, in page order. */
1003   public List<CardSpec> cards() { return u(cards); }
1004
1005   /** @return Template id to markup, in emit order. */
1006   public Map<String,String> templates() { return u(templates); }
1007
1008   /**
1009    * Serializes the contract.
1010    *
1011    * @return Script-safe contract JSON (every {@code <} written as the JSON unicode escape).
1012    */
1013   public String toContractJson() {
1014      var m = new LinkedHashMap<String,Object>();
1015      m.put("contractVersion", CONTRACT_VERSION);
1016      m.put("title", title);
1017      if (nn(theme))
1018         m.put("theme", Map.of("name", theme));
1019      var h = headerMap();
1020      if (! h.isEmpty())
1021         m.put("header", h);
1022      var f = footerMap();
1023      if (! f.isEmpty())
1024         m.put("footer", f);
1025      if (nn(navLayout))
1026         m.put("navLayout", navLayout);
1027      m.put("nav", navRoot.toList());
1028      m.put("activeNav", activeNav);
1029      m.put("cards", cards.stream().map(CardSpec::toMap).toList());
1030      if (! topics.isEmpty())
1031         m.put("topics", List.copyOf(topics.values()));
1032      if (! bridges.isEmpty())
1033         m.put("bridges", List.copyOf(bridges.values()));
1034      if (! dataSources.isEmpty())
1035         m.put("dataSources", List.copyOf(dataSources.values()));
1036      if (! facts.isEmpty())
1037         m.put("facts", facts);
1038      if (dashboard != null)
1039         m.put("dashboard", dashboard);
1040      return scriptSafeJson(Json.DEFAULT.write(m));
1041   }
1042
1043   /**
1044    * Writes the contract island, the templates and the shell script tag.
1045    *
1046    * @param out The writer.
1047    * @throws IOException On an I/O error.
1048    */
1049   public void writeBody(Writer out) throws IOException {
1050      out.write("<script type=\"application/json\" id=\"juneau-page\">");
1051      out.write(toContractJson());
1052      out.write("</script>");
1053      for (var e : templates.entrySet()) {
1054         var attr = e.getKey().contains(".") ? "data-slot" : "data-card";
1055         out.write("<template " + attr + "=\"" + e.getKey() + "\">");
1056         out.write(e.getValue());
1057         out.write("</template>");
1058      }
1059      out.write("<script src=\"" + shellUrl + "\"></script>");
1060   }
1061
1062   static String scriptSafeJson(String json) {
1063      return json.replace("<", "\\u003c");
1064   }
1065
1066   private Map<String,Object> headerMap() {
1067      var m = new LinkedHashMap<String,Object>();
1068      if (nn(header.title)) m.put("title", header.title);
1069      if (nn(header.subtitle)) m.put("subtitle", header.subtitle);
1070      if (nn(header.logoSrc)) {
1071         var l = new LinkedHashMap<String,Object>();
1072         l.put("src", header.logoSrc);
1073         if (nn(header.logoHref)) l.put("href", header.logoHref);
1074         if (nn(header.logoAlt)) l.put("alt", header.logoAlt);
1075         m.put("logo", l);
1076      }
1077      if (! header.links.isEmpty()) m.put("links", header.links);
1078      if (nn(header.userMenu)) m.put("userMenu", header.userMenu);
1079      if (header.chrome) m.put("chrome", true);
1080      if (slots.containsKey("header")) m.put("slots", slots.get("header"));
1081      if (! badges.isEmpty()) m.put("badges", badges.values().stream().map(BadgeDef::toContractMap).toList());
1082      return m;
1083   }
1084
1085   private Map<String,Object> footerMap() {
1086      var m = new LinkedHashMap<String,Object>();
1087      if (nn(footer.text)) m.put("text", footer.text);
1088      if (! footer.links.isEmpty()) m.put("links", footer.links);
1089      if (slots.containsKey("footer")) m.put("slots", slots.get("footer"));
1090      return m;
1091   }
1092}