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.*; 023 024import freemarker.core.*; 025import freemarker.template.*; 026 027/** 028 * The recursive {@code <@node>} console FreeMarker directive: one entry in a {@code <@navigation>} tree, of any 029 * depth, or (with {@code under=}) a page-scoped addition appended under an existing chrome nav node. 030 * 031 * <p> 032 * Attributes: {@code id} (required, checked against the id grammar — E-4), {@code label} (required), 033 * {@code href} (required on a leaf — E-2), {@code visible} (a strict boolean, omit defaults to {@code true} 034 * — E-5), {@code selected} (a strict boolean, omit defaults to {@code false} — E-5), and {@code under} 035 * (a chrome nav id path; see below). {@code format=} and any unknown attribute are rejected. A duplicate sibling id is rejected (E-3). At most one node tree-wide may set 036 * {@code selected=true} (E-6). 037 * 038 * <p> 039 * {@code visible=false} omits the node and its whole subtree — the body is not even rendered. A node not 040 * nested inside {@code <@navigation>} and without {@code under=} is rejected fail-closed. 041 * 042 * <h5 class='section'>Example:</h5> 043 * <p class='bcode'> 044 * <@navigation> 045 * <@node id="work" label="Work" href="/work"> 046 * <@node id="work-queue" label="Queue" href="/work/queue"/> 047 * </@node> 048 * <@node id="setup" label="Setup" href="/setup" visible=isAdmin/> 049 * </@navigation> 050 * </p> 051 * 052 * <h5 class='section'>{@code under=}: page-scoped nav additions:</h5> 053 * <p> 054 * {@code under=} is allowed only on a {@code <@node>} that is a direct child of {@code <@page>}, or of 055 * {@code <@console>} outside {@code <@navigation>}; anywhere else it is E-B14. The node (and any ordinary nested 056 * {@code <@node>} children, built exactly as inside {@code <@navigation>}) is recorded as a pending addition and 057 * applied at {@code </@console>}, before {@code activeNav} resolves. An unknown {@code under=} path is E-B8; a 058 * duplicate sibling under the resolved parent is E-3. {@link PageSpec#navUnder(String, PageCapture.NavAdder)} is 059 * the Java twin; both funnel into the same resolution pass. Pending additions resolve in order — Java 060 * {@code navUnder} calls first, then {@code <@node under>} in document order — so an addition can target only 061 * a node created by an earlier one; a forward reference is E-B8. 062 * <p class='bcode'> 063 * <@page tab="fleet/instances/${instance.id}/logs"> 064 * <@node under="fleet/instances" id=instance.id label=instance.name href="/fleet/instances/${instance.id}"> 065 * <@node id="logs" label="Logs" href="/fleet/instances/${instance.id}/logs" selected=true/> 066 * </@node> 067 * </@page> 068 * </p> 069 * 070 * @since 10.0.0 071 */ 072@SuppressWarnings({ 073 "java:S1192", // Repeated literals are contract wire keys; named constants would obscure the contract shape. 074 "java:S3776" // Validation and rendering chains many independent guards; splitting them adds no clarity. 075}) 076public final class NodeDirectiveModel implements TemplateDirectiveModel { 077 078 /** The shared-variable name this directive registers under. */ 079 public static final String NAME = "node"; 080 081 static final Set<String> ATTRS = Set.of("id", "label", "href", "visible", "selected", "under"); 082 083 NodeDirectiveModel() {} 084 085 @Override 086 @SuppressWarnings({ 087 "unchecked" // FreeMarker's raw params Map is String-keyed by contract. 088 }) 089 public void execute(Environment env, @SuppressWarnings("rawtypes") Map params, TemplateModel[] loopVars, 090 TemplateDirectiveBody body) throws TemplateException, IOException { 091 Map<String, TemplateModel> p = params; 092 if (p.containsKey("format")) 093 throw FtlAttrLists.reject("<@node> has no format= attribute."); 094 FtlAttrLists.rejectUnknown(p, NAME, ATTRS); 095 096 var id = FtlAttrLists.scalar(p, "id"); 097 if (id.isEmpty()) 098 throw FtlAttrLists.reject("<@node> requires id=."); 099 FtlAttrLists.checkId(NAME, id); 100 var label = FtlAttrLists.scalar(p, "label"); 101 if (label.isEmpty()) 102 throw FtlAttrLists.reject("<@node> requires label=."); 103 var href = FtlAttrLists.scalar(p, "href"); 104 var visible = FtlAttrLists.strictBoolean(p, NAME, "visible", true); 105 var selected = FtlAttrLists.strictBoolean(p, NAME, "selected", false); 106 var under = FtlAttrLists.scalar(p, "under"); 107 108 if (p.containsKey("under")) 109 for (var segment : under.split("/", -1)) 110 if (segment.isBlank()) 111 throw FtlAttrLists.reject(f( 112 "<@node id='%s'> under must name a '/'-separated nav id path; got '%s'.", id, under)); 113 114 var cap = PageCapture.get(env); 115 if (! under.isEmpty()) { 116 if (n(cap) || cap.navOpen || cap.cardOpen || cap.slotOpen || ! (cap.pageOpen || cap.consoleOpen)) 117 throw FtlAttrLists.reject(f( 118 "<@node id='%s' under='%s'> must be a direct child of <@page> or <@console>, outside <@navigation>.", id, under)); 119 } else if (n(cap) || ! cap.navOpen) { 120 throw FtlAttrLists.reject("<@node> must be nested inside <@navigation>."); 121 } 122 123 // visible=false omits the node and its whole subtree; the body is not rendered. 124 if (! visible) 125 return; 126 127 if (! under.isEmpty()) { 128 buildUnder(cap, under, id, label, href, selected, body); 129 return; 130 } 131 132 var node = cap.navCursor.peek().add(id, label, href.isEmpty() ? null : href); 133 cap.navCursor.push(node); 134 try (var sink = new StringWriter()) { 135 if (nn(body)) 136 body.render(sink); 137 } finally { 138 cap.navCursor.pop(); 139 } 140 141 if (node.children().isEmpty() && n(node.href())) 142 throw FtlAttrLists.reject(f("<@node id='%s'> leaf requires href=.", id)); 143 if (selected) { 144 if (cap.underRootOpen) { 145 if (nn(cap.underRootSelected)) 146 throw FtlAttrLists.reject(f("<@node id='%s'> selected=true but '%s' is already selected.", 147 node.id(), underFullPath(cap, cap.underRootSelected))); 148 cap.underRootSelected = node; 149 } else { 150 cap.select(node.idPath()); 151 } 152 } 153 } 154 155 // under=: builds the node (and its ordinary nested children) against a throwaway detached root, then queues 156 // a NavAdder that copies the finished subtree onto the real, resolved ancestor at </@console>. 157 private static void buildUnder(PageCapture cap, String under, String id, String label, String href, 158 boolean selected, TemplateDirectiveBody body) throws TemplateException, IOException { 159 var top = NavNode.root().add(id, label, href.isEmpty() ? null : href); 160 cap.navCursor.push(top); 161 cap.navOpen = true; 162 cap.underRootOpen = true; 163 cap.underRootParent = under; 164 try (var sink = new StringWriter()) { 165 if (nn(body)) 166 body.render(sink); 167 } finally { 168 cap.navCursor.pop(); 169 cap.navOpen = false; 170 cap.underRootOpen = false; 171 } 172 if (top.children().isEmpty() && n(top.href())) 173 throw FtlAttrLists.reject(f("<@node id='%s'> leaf requires href=.", id)); 174 175 var selectedMarker = cap.underRootSelected; 176 cap.underRootSelected = null; 177 if (selected) { 178 if (nn(selectedMarker)) 179 throw FtlAttrLists.reject(f("<@node id='%s'> selected=true but '%s' is already selected.", 180 id, underFullPath(cap, selectedMarker))); 181 selectedMarker = top; 182 } 183 184 var marker = selectedMarker; 185 cap.addPendingNavUnder(under, parent -> { 186 var found = new NavNode[1]; 187 copySubtree(top, parent, marker, found); 188 if (nn(found[0])) 189 cap.select(found[0].idPath()); 190 }); 191 } 192 193 // The selected node's full path, as it will be once attached: the under= path followed by its detached path. 194 private static String underFullPath(PageCapture cap, NavNode detached) { 195 return cap.underRootParent + "/" + String.join("/", detached.idPath()); 196 } 197 198 // Recursively copies a detached subtree onto a real, resolved ancestor; NavNode has no re-parenting operation, 199 // so every node is rebuilt via its public getters. 200 private static NavNode copySubtree(NavNode src, NavNode dstParent, NavNode selectedMarker, NavNode[] found) 201 throws TemplateModelException { 202 var copy = dstParent.add(src.id(), src.label(), src.href()); 203 if (src == selectedMarker) 204 found[0] = copy; 205 for (var child : src.children()) 206 copySubtree(child, copy, selectedMarker, found); 207 return copy; 208 } 209}