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> -> <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> -> <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}