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 capture-only console slot directives: {@code <@head>}, {@code <@scripts>}, {@code <@brand>}, 029 * {@code <@actions>}, {@code <@title>}, {@code <@footer>}, and {@code <@body>}. One parameterized instance is 030 * registered per slot name (its {@link PageCapture} field), so the seven directives share this one class. 031 * 032 * <p> 033 * Each is authored inside {@code <@console>}. During the console's body pass this directive <b>captures</b> its 034 * rendered body into the matching {@link PageCapture} field and emits <b>nothing</b> at the author's position; 035 * {@code <@console>} then places each captured slot into the fixed document at its correct spot (chrome-level 036 * {@code <@head>}/{@code <@scripts>} in the head/end-of-body pass-throughs; {@code <@brand>}/{@code <@actions>}/ 037 * {@code <@title>} in the header; {@code <@footer>} as the single footer; {@code <@body>} spliced onto the 038 * {@code <body>} tag). A slot authored outside a {@code <@console>} (no {@link PageCapture} in scope) is 039 * rejected fail-closed. 040 * 041 * <h5 class='section'>{@code phase=} (head/scripts only):</h5> 042 * <p> 043 * {@code <@head>} and {@code <@scripts>} accept an optional {@code phase=} that steers the capture to a distinct 044 * cascade position: {@code <@head phase="before-page-css">} lands app CSS <b>before</b> the page-local 045 * {@code <@page css=>} links (e.g. {@code a.css}), so a page {@code css=} rule still wins, and 046 * {@code <@scripts phase="after-toolkit">} lands scripts <b>between</b> the toolkit JS and the page-local 047 * {@code <@page init=>} scripts (e.g. {@code one.js}). Any other slot rejects {@code phase=}; an 048 * unrecognized value is rejected fail-closed. 049 * 050 * @since 10.0.0 051 */ 052@SuppressWarnings({ 053 "java:S1192" // Duplicated literals read more clearly inline than as constants 054}) 055public final class ConsoleSlotDirectiveModel implements TemplateDirectiveModel { 056 057 /** Slot names; each is also the shared-variable name the directive registers under. */ 058 static final String HEAD = "head"; 059 static final String SCRIPTS = "scripts"; 060 static final String BRAND = "brand"; 061 static final String ACTIONS = "actions"; 062 static final String TITLE = "title"; 063 static final String FOOTER = "footer"; 064 static final String BODY = "body"; 065 066 /** Every slot name, in registration order. */ 067 static final List<String> SLOT_NAMES = List.of(HEAD, SCRIPTS, BRAND, ACTIONS, TITLE, FOOTER, BODY); 068 069 /** {@code <@head phase>} value that lands the markup before the page-local {@code css=} links. */ 070 static final String PHASE_BEFORE_PAGE_CSS = "before-page-css"; 071 072 /** {@code <@scripts phase>} value that lands the markup between the toolkit JS and {@code init=}. */ 073 static final String PHASE_AFTER_TOOLKIT = "after-toolkit"; 074 075 private static final String PHASE = "phase"; 076 private static final String TEXT = "text"; 077 078 private final String slot; 079 080 /** 081 * @param slot One of the slot-name constants; also the shared-variable name this directive is registered under. 082 */ 083 ConsoleSlotDirectiveModel(String slot) { 084 this.slot = slot; 085 } 086 087 /** @return The attributes this slot accepts (read by {@link ConsoleTemplateValidator}). */ 088 Set<String> attrs() { 089 if (eq(HEAD, slot) || eq(SCRIPTS, slot)) 090 return Set.of(PHASE); 091 if (eq(FOOTER, slot)) 092 return Set.of(TEXT); 093 return Set.of(); 094 } 095 096 @Override 097 @SuppressWarnings({ 098 "java:S3776", // Validation and slot dispatch read best as one straight-line method. 099 "unchecked" // FreeMarker's raw params Map is String-keyed by contract. 100 }) 101 public void execute(Environment env, @SuppressWarnings("rawtypes") Map params, TemplateModel[] loopVars, 102 TemplateDirectiveBody body) throws TemplateException, IOException { 103 Map<String, TemplateModel> p = params; 104 if (p.containsKey("format")) 105 throw FtlAttrLists.reject("<@" + slot + "> has no format= attribute."); 106 FtlAttrLists.rejectUnknown(p, slot, attrs()); 107 108 var phase = FtlAttrLists.scalar(p, PHASE); 109 if (! phase.isEmpty()) { 110 var ok = (eq(HEAD, slot) && eq(phase, PHASE_BEFORE_PAGE_CSS)) 111 || (eq(SCRIPTS, slot) && eq(phase, PHASE_AFTER_TOOLKIT)); 112 if (! ok) 113 throw FtlAttrLists.reject("<@" + slot + "> unknown phase '" + phase + "'."); 114 } 115 116 var cap = PageCapture.get(env); 117 if (n(cap) || ! cap.consoleOpen) 118 throw FtlAttrLists.reject(f("<@%s> must be nested inside <@console>.", slot)); 119 120 String markup; 121 cap.slotOpen = true; 122 try { 123 markup = PageCapture.render(body); 124 } finally { 125 cap.slotOpen = false; 126 } 127 switch (slot) { 128 case HEAD -> { 129 if (phase.isEmpty()) 130 cap.head = markup; 131 else 132 cap.headBeforePageCss = markup; 133 } 134 case SCRIPTS -> { 135 if (phase.isEmpty()) 136 cap.scripts = markup; 137 else 138 cap.scriptsAfterToolkit = markup; 139 } 140 // Captured, not merged, here: the merge needs the spec side too, so it runs once at </@console>. 141 case BODY -> cap.ftlBodyAttrs = markup; 142 case BRAND -> cap.addSlot("header", "brand", markup); 143 case ACTIONS -> cap.addSlot("header", "actions", markup); 144 case TITLE -> cap.addSlot("header", "replace", markup); 145 case FOOTER -> footer(cap, FtlAttrLists.scalar(p, TEXT), markup); 146 default -> throw new IllegalStateException(slot); 147 } 148 } 149 150 private static void footer(PageCapture cap, String text, String markup) throws TemplateModelException { 151 if (! text.isEmpty()) { 152 if (! markup.isBlank()) 153 throw FtlAttrLists.reject(f( 154 "<@footer text='%s'> also has a body; use text= for plain text or the body for markup, not both.", text)); 155 cap.footer(f -> f.text(text)); 156 } else if (! markup.isBlank()) { 157 cap.addSlot("footer", "content", markup); 158 } 159 } 160}