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}