diff --git a/groovy/src/main/java/org/kohsuke/stapler/jelly/groovy/JellyBuilder.java b/groovy/src/main/java/org/kohsuke/stapler/jelly/groovy/JellyBuilder.java index 1745fb964..9b99cb8e5 100644 --- a/groovy/src/main/java/org/kohsuke/stapler/jelly/groovy/JellyBuilder.java +++ b/groovy/src/main/java/org/kohsuke/stapler/jelly/groovy/JellyBuilder.java @@ -469,6 +469,7 @@ public Object with(XMLOutput out, Closure c) { * @return null * if nothing was generated. */ + // TODO apparently unused public Element redirectToDom(Closure c) { SAXContentHandler sc = new SAXContentHandler(); with(new XMLOutput(sc), c); diff --git a/html/pom.xml b/html/pom.xml new file mode 100644 index 000000000..141afeeea --- /dev/null +++ b/html/pom.xml @@ -0,0 +1,81 @@ + + + 4.0.0 + + org.kohsuke.stapler + stapler-parent + ${changelist} + + stapler-html + + + ${project.groupId} + stapler-jelly + ${project.version} + + + org.kohsuke.metainf-services + metainf-services + true + + + jakarta.servlet + jakarta.servlet-api + 5.0.0 + provided + + + ${project.groupId} + stapler + ${project.version} + tests + test + + + org.eclipse.jetty + jetty-server + test + + + org.eclipse.jetty + jetty-util + test + + + org.eclipse.jetty.ee9 + jetty-ee9-servlet + test + + + org.eclipse.jetty.ee9 + jetty-ee9-webapp + test + + + org.hamcrest + hamcrest + test + + + org.jenkins-ci.main + jenkins-test-harness-htmlunit + test + + + org.junit.jupiter + junit-jupiter + test + + + org.junit.vintage + junit-vintage-engine + test + + + org.slf4j + slf4j-jdk14 + 2.0.16 + test + + + diff --git a/html/src/main/java/org/kohsuke/stapler/html/HtmlInclude.java b/html/src/main/java/org/kohsuke/stapler/html/HtmlInclude.java new file mode 100644 index 000000000..cfd0b5a54 --- /dev/null +++ b/html/src/main/java/org/kohsuke/stapler/html/HtmlInclude.java @@ -0,0 +1,20 @@ +package org.kohsuke.stapler.html; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Denotes that a record field should include another view. + * The field type should be Stapler-dispatchable. + */ +@Target(ElementType.RECORD_COMPONENT) +@Retention(RetentionPolicy.RUNTIME) +public @interface HtmlInclude { + /** + * The name of the view to include. + * No file extension should be used, so for example use {@code index} or {@code config}. + */ + String value(); +} diff --git a/html/src/main/java/org/kohsuke/stapler/html/HtmlView.java b/html/src/main/java/org/kohsuke/stapler/html/HtmlView.java new file mode 100644 index 000000000..867d010c8 --- /dev/null +++ b/html/src/main/java/org/kohsuke/stapler/html/HtmlView.java @@ -0,0 +1,27 @@ +package org.kohsuke.stapler.html; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.util.List; + +/** + * A method which renders an HTML view. + * The method must be public, take no arguments, and return a {@link Record}. + * The fields of the record must align with the element {@code id}s + * with names starting with the {@code st.} prefix. + * The following field types are permitted: + * + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface HtmlView { + /** View name, which should be {@code *.xhtml} base name. */ + String value(); +} diff --git a/html/src/main/java/org/kohsuke/stapler/html/impl/HtmlClassLoaderTearOff.java b/html/src/main/java/org/kohsuke/stapler/html/impl/HtmlClassLoaderTearOff.java new file mode 100644 index 000000000..20f3b6514 --- /dev/null +++ b/html/src/main/java/org/kohsuke/stapler/html/impl/HtmlClassLoaderTearOff.java @@ -0,0 +1,48 @@ +package org.kohsuke.stapler.html.impl; + +import java.net.URL; +import java.util.logging.Logger; +import org.dom4j.io.SAXReader; +import org.kohsuke.stapler.MetaClass; +import org.kohsuke.stapler.MetaClassLoader; +import org.kohsuke.stapler.html.HtmlView; + +public final class HtmlClassLoaderTearOff { + private static final Logger LOGGER = Logger.getLogger(HtmlClassLoaderTearOff.class.getName()); + private final MetaClassLoader owner; + private final SAXReader parser; + + public HtmlClassLoaderTearOff(MetaClassLoader owner) throws Exception { + this.owner = owner; + parser = new SAXReader(); + parser.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false); + parser.setFeature("http://xml.org/sax/features/external-general-entities", false); + parser.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + // TODO enable validation against XHTML 1.1 schema when in test mode (-ea) + } + + public HtmlJellyScript parse(URL script, MetaClass owner) throws Exception { + LOGGER.info(() -> "TODO parsing " + script + " from " + owner); + Class c = owner.klass.toJavaClass(); + var base = c.getProtectionDomain().getCodeSource().getLocation() + + c.getName().replace('.', '/').replace('$', '/') + "/"; + for (var m : c.getMethods()) { + var ann = m.getAnnotation(HtmlView.class); + if (ann == null) { + continue; + } + if (m.getParameterCount() > 0) { + throw new Exception(m + " must not take arguments"); + } + if (!m.getReturnType().isRecord()) { + throw new Exception(m + " must return a record"); + } + // TODO verify that the Record fields & nesting match st.* DOM structure + // (or do this all at compile time using an annotation processor) + if (script.toString().equals(base + ann.value() + ".xhtml")) { + return new HtmlJellyScript(m, parser.read(script).getRootElement()); + } + } + throw new Exception(c + " does not have a @HtmlView corresponding to " + script); + } +} diff --git a/html/src/main/java/org/kohsuke/stapler/html/impl/HtmlClassTearOff.java b/html/src/main/java/org/kohsuke/stapler/html/impl/HtmlClassTearOff.java new file mode 100644 index 000000000..7ca466ad9 --- /dev/null +++ b/html/src/main/java/org/kohsuke/stapler/html/impl/HtmlClassTearOff.java @@ -0,0 +1,25 @@ +package org.kohsuke.stapler.html.impl; + +import java.net.URL; +import java.util.logging.Logger; +import org.kohsuke.stapler.AbstractTearOff; +import org.kohsuke.stapler.MetaClass; + +public final class HtmlClassTearOff extends AbstractTearOff { + private static final Logger LOGGER = Logger.getLogger(HtmlClassTearOff.class.getName()); + + public HtmlClassTearOff(MetaClass owner) { + super(owner, HtmlClassLoaderTearOff.class); + LOGGER.fine(() -> "initialized " + owner); + } + + @Override + protected String getDefaultScriptExtension() { + return ".xhtml"; + } + + @Override + public HtmlJellyScript parseScript(URL res) throws Exception { + return classLoader.parse(res, owner); + } +} diff --git a/html/src/main/java/org/kohsuke/stapler/html/impl/HtmlFacet.java b/html/src/main/java/org/kohsuke/stapler/html/impl/HtmlFacet.java new file mode 100644 index 000000000..26cfc1a7d --- /dev/null +++ b/html/src/main/java/org/kohsuke/stapler/html/impl/HtmlFacet.java @@ -0,0 +1,84 @@ +package org.kohsuke.stapler.html.impl; + +import static org.kohsuke.stapler.Facet.LOGGER; + +import jakarta.servlet.RequestDispatcher; +import jakarta.servlet.ServletException; +import java.io.IOException; +import java.util.Collection; +import java.util.List; +import java.util.Set; +import java.util.logging.Level; +import java.util.logging.Logger; +import org.apache.commons.jelly.JellyException; +import org.apache.commons.jelly.Script; +import org.kohsuke.MetaInfServices; +import org.kohsuke.stapler.AbstractTearOff; +import org.kohsuke.stapler.Dispatcher; +import org.kohsuke.stapler.Facet; +import org.kohsuke.stapler.MetaClass; +import org.kohsuke.stapler.RequestImpl; +import org.kohsuke.stapler.ResponseImpl; +import org.kohsuke.stapler.jelly.JellyClassTearOff; +import org.kohsuke.stapler.jelly.JellyCompatibleFacet; +import org.kohsuke.stapler.jelly.JellyFacet; +import org.kohsuke.stapler.lang.Klass; + +@MetaInfServices(Facet.class) +public final class HtmlFacet extends Facet implements JellyCompatibleFacet { + + private static final Logger LOGGER = Logger.getLogger(HtmlFacet.class.getName()); + + // TODO seems like there could be some default methods in JellyCompatibleFacet to avoid boilerplate + + @Override + public void buildViewDispatchers(MetaClass owner, List dispatchers) { + dispatchers.add(createValidatingDispatcher( + owner.loadTearOff(HtmlClassTearOff.class), owner.webApp.getFacet(JellyFacet.class).scriptInvoker)); + } + + @Override + public RequestDispatcher createRequestDispatcher(RequestImpl request, Klass type, Object it, String viewName) + throws IOException { + // TODO is this actually used? + return createRequestDispatcher( + request.getWebApp().getMetaClass(type).loadTearOff(HtmlClassTearOff.class), + request.getWebApp().getFacet(JellyFacet.class).scriptInvoker, + it, + viewName); + } + + @Override + public void buildIndexDispatchers(MetaClass owner, List dispatchers) { + try { + if (owner.loadTearOff(JellyClassTearOff.class).findScript("index") != null) { + super.buildIndexDispatchers(owner, dispatchers); + } + } catch (JellyException e) { + LOGGER.log(Level.WARNING, "Failed to parse index.xhtml for " + owner, e); + } + } + + @Override + public boolean handleIndexRequest(RequestImpl req, ResponseImpl rsp, Object node, MetaClass nodeMetaClass) + throws IOException, ServletException { + return handleIndexRequest( + nodeMetaClass.loadTearOff(HtmlClassTearOff.class), + req.getWebApp().getFacet(JellyFacet.class).scriptInvoker, + req, + rsp, + node); + } + + @Override + public Collection>> getClassTearOffTypes() { + return Set.of(HtmlClassTearOff.class); + } + + @Override + public Collection getScriptExtensions() { + // TODO allow *.html if it can be parsed without external deps + // or use *.xml? + return Set.of(".xhtml"); + } +} diff --git a/html/src/main/java/org/kohsuke/stapler/html/impl/HtmlJellyScript.java b/html/src/main/java/org/kohsuke/stapler/html/impl/HtmlJellyScript.java new file mode 100644 index 000000000..b1dba3211 --- /dev/null +++ b/html/src/main/java/org/kohsuke/stapler/html/impl/HtmlJellyScript.java @@ -0,0 +1,149 @@ +package org.kohsuke.stapler.html.impl; + +import edu.umd.cs.findbugs.annotations.CheckForNull; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; +import java.util.logging.Logger; +import org.apache.commons.jelly.JellyContext; +import org.apache.commons.jelly.JellyException; +import org.apache.commons.jelly.JellyTagException; +import org.apache.commons.jelly.Script; +import org.apache.commons.jelly.XMLOutput; +import org.dom4j.Element; +import org.dom4j.Node; +import org.dom4j.io.SAXContentHandler; +import org.dom4j.io.SAXWriter; +import org.kohsuke.stapler.WebApp; +import org.kohsuke.stapler.html.HtmlInclude; +import org.kohsuke.stapler.jelly.JellyClassTearOff; + +final class HtmlJellyScript implements Script { + + private static final Logger LOGGER = Logger.getLogger(HtmlJellyScript.class.getName()); + + private final Method method; + private final Element root; + + HtmlJellyScript(Method method, Element root) { + this.method = method; + this.root = root; + } + + @Override + public Script compile() throws JellyException { + return this; + } + + @Override + public void run(JellyContext context, XMLOutput output) throws JellyTagException { + var it = context.getVariable("it"); + if (it == null) { + throw new JellyTagException("No `it` bound"); + } + // TODO set thread name like JellyViewScript does + try { + var record = (Record) method.invoke(it); + LOGGER.info(() -> "TODO " + method + " on " + it + " ⇒ " + record); + // TODO find a more efficient way to render without allocation: + var rendered = (Element) root.clone(); + render(context, rendered, record); + new SAXWriter(output, output).write(rendered); + } catch (Exception x) { + throw new JellyTagException(x); + } + } + + /** + * Render a view or subtree of a view. + * {@link Visitor} cannot be used easily here because it lacks any way to prune a tree + * or record entry and exit from an element. + */ + private void render(JellyContext context, Element rendered, Record record) throws Exception { + for (var field : record.getClass().getRecordComponents()) { + var id = "st." + field.getName(); + var elt = find(rendered, id); + if (elt == null) { + throw new IllegalArgumentException("did not find " + id + " in " + rendered); + } + elt.remove(elt.attribute("id")); + var value = field.getAccessor().invoke(record); + var include = field.getAnnotation(HtmlInclude.class); + if (include != null) { + var metaClass = WebApp.getCurrent().getMetaClass(value.getClass()); + var script = metaClass.loadTearOff(JellyClassTearOff.class).findScript(include.value()); + var subcontext = new JellyContext(context); + subcontext.setExportLibraries(false); // defaults to true, weirdly + subcontext.setVariable("from", value); + subcontext.setVariable("it", value); + var handler = new SAXContentHandler(); + var oldCCL = Thread.currentThread().getContextClassLoader(); + Thread.currentThread().setContextClassLoader(metaClass.classLoader.loader); + try { + script.run(subcontext, new XMLOutput(handler)); + } finally { + Thread.currentThread().setContextClassLoader(oldCCL); + } + // TODO honor JellyFacet.TRACE somehow + replace(elt, List.of(handler.getDocument().getRootElement())); + } else if (value instanceof String text) { + // TODO why do we need to replace entities like this? + // It is not necessary if run calls rendered.write(output.asWriter()) + // but that does not seem right because it would be just sending text content, not XML. + // (And then includes do not work at all: the SAXContentHandler is left empty.) + // Also using SAXWriter causes DefaultScriptInvoker.createXMLOutput to use HTMLWriterOutput + // and thus dropping

, which does not match what actual Jenkins text/html output is like. + // Retest in the context of Jenkins which might wrap things differently. + elt.setText(text.replace("&", "&").replace("<", "<").replace(">", ">")); + } else if (value instanceof Boolean enabled) { + if (!enabled) { + elt.detach(); + } + } else if (value instanceof Record subrecord) { + render(context, elt, subrecord); + } else if (value instanceof List list) { + var replacements = new ArrayList(); + for (var item : list) { + var elt2 = (Element) elt.clone(); + render(context, elt2, (Record) item); + replacements.add(elt2); + } + replace(elt, replacements); + } else if (value == null) { + elt.detach(); + } else { + throw new IllegalArgumentException("did not recognize " + value); + } + } + } + + /** + * Like {@link Element#elementByID} except using attribute {@code id} not {@code ID}. + */ + @CheckForNull + private static Element find(Element elt, String id) { + if (id.equals(elt.attributeValue("id"))) { + return elt; + } + for (var child : elt.elements()) { + var found = find(child, id); + if (found != null) { + return found; + } + } + return null; + } + + // TODO dom4j does not seem to define an insertAt or replace + // also Element.node(int) does not work as documented + private static void replace(Element elt, List replacements) { + var parent = elt.getParent(); + var kids = new ArrayList(); + parent.nodeIterator().forEachRemaining(kids::add); + kids.stream().forEach(Node::detach); + int index = kids.indexOf(elt); + kids.remove(index); + kids.addAll(index, replacements); + kids.stream().forEach(parent::add); + } +} diff --git a/html/src/test/java/org/kohsuke/stapler/html/HtmlTestCase.java b/html/src/test/java/org/kohsuke/stapler/html/HtmlTestCase.java new file mode 100644 index 000000000..d89761323 --- /dev/null +++ b/html/src/test/java/org/kohsuke/stapler/html/HtmlTestCase.java @@ -0,0 +1,19 @@ +package org.kohsuke.stapler.html; + +import org.kohsuke.stapler.test.JettyTestCase; + +public abstract class HtmlTestCase extends JettyTestCase { + + // TODO enable fine logging in subpackages + + protected final String load(String uri) throws Exception { + return createWebClient() + .getPage(url.toURI().resolve(uri).toURL()) + .getWebResponse() + .getContentAsString() + .replaceAll("", " ") + .replaceAll("\\s+", " ") + .trim() + .replace('"', '\''); + } +} diff --git a/html/src/test/java/org/kohsuke/stapler/html/IncludedFromTest.java b/html/src/test/java/org/kohsuke/stapler/html/IncludedFromTest.java new file mode 100644 index 000000000..de8d5d79e --- /dev/null +++ b/html/src/test/java/org/kohsuke/stapler/html/IncludedFromTest.java @@ -0,0 +1,21 @@ +package org.kohsuke.stapler.html; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; + +public final class IncludedFromTest extends HtmlTestCase { + + public void testIncludedFromJelly() throws Exception { + assertThat( + load("top"), + is( + "A prologue.
Center text includes a special <cool> value.
An epilogue.")); + } + + @HtmlView("center") + public Center getCenter() { + return new Center("special value"); + } + + public record Center(String value) {} +} diff --git a/html/src/test/java/org/kohsuke/stapler/html/IncludesTest.java b/html/src/test/java/org/kohsuke/stapler/html/IncludesTest.java new file mode 100644 index 000000000..55eae3250 --- /dev/null +++ b/html/src/test/java/org/kohsuke/stapler/html/IncludesTest.java @@ -0,0 +1,31 @@ +package org.kohsuke.stapler.html; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; + +public final class IncludesTest extends HtmlTestCase { + + public void testIncludes() throws Exception { + assertThat( + load("top"), + is( + "
A prologue. There are 23 items. An epilogue.
")); + } + + @HtmlView("top") + public Top getTop() { + return new Top(new Nested()); + } + + public record Top(@HtmlInclude("center") Nested nested) {} + + public static final class Nested { + + @HtmlView("center") + public Center getCenter() { + return new Center("23"); + } + + public record Center(String count) {} + } +} diff --git a/html/src/test/java/org/kohsuke/stapler/html/StructureTest.java b/html/src/test/java/org/kohsuke/stapler/html/StructureTest.java new file mode 100644 index 000000000..646653836 --- /dev/null +++ b/html/src/test/java/org/kohsuke/stapler/html/StructureTest.java @@ -0,0 +1,59 @@ +package org.kohsuke.stapler.html; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.not; + +import edu.umd.cs.findbugs.annotations.CheckForNull; +import java.util.List; + +public final class StructureTest extends HtmlTestCase { + + Status status; + + public void testStandalone() throws Exception { + status = new Status( + "Something #123", + null, + false, + new Status.Items( + List.of(new Status.Items.Element("k1", "v1"), new Status.Items.Element("k2", "v2 &c.")))); + assertThat( + load("standalone"), + allOf( + containsString("Something #123"), + containsString("

All items: " + + " " + + " " + + " " + + " " + + "
Initial nameInitial value
k1 v1
k2 v2 &c.
Penultimate namePenultimate value
Final nameFinal value
"), + not(containsString("Error in")), + not(containsString("There are")))); + status = new Status("Something #456", new Status.Warning("rotor"), true, null); + assertThat( + load("standalone"), + allOf( + containsString("Something #456"), + not(containsString("All items:")), + containsString("

Error in rotor."), + containsString("

There are no items."))); + } + + @HtmlView("standalone") + public Status getStandalone() throws Exception { + return status; + } + + public record Status( + String displayName, @CheckForNull Warning warning, boolean empty, @CheckForNull Items nonempty) { + + public record Warning(String component) {} + + public record Items(List elements) { + + public record Element(String name, String value) {} + } + } +} diff --git a/html/src/test/resources/org/kohsuke/stapler/html/IncludedFromTest/center.xhtml b/html/src/test/resources/org/kohsuke/stapler/html/IncludedFromTest/center.xhtml new file mode 100644 index 000000000..a6777fa92 --- /dev/null +++ b/html/src/test/resources/org/kohsuke/stapler/html/IncludedFromTest/center.xhtml @@ -0,0 +1,5 @@ + + +

+ Center text includes a value. +
diff --git a/html/src/test/resources/org/kohsuke/stapler/html/IncludedFromTest/top.jelly b/html/src/test/resources/org/kohsuke/stapler/html/IncludedFromTest/top.jelly new file mode 100644 index 000000000..e7a589fb8 --- /dev/null +++ b/html/src/test/resources/org/kohsuke/stapler/html/IncludedFromTest/top.jelly @@ -0,0 +1,7 @@ + + + + A prologue. + + An epilogue. + diff --git a/html/src/test/resources/org/kohsuke/stapler/html/IncludesTest/Nested/center.xhtml b/html/src/test/resources/org/kohsuke/stapler/html/IncludesTest/Nested/center.xhtml new file mode 100644 index 000000000..d5197bf44 --- /dev/null +++ b/html/src/test/resources/org/kohsuke/stapler/html/IncludesTest/Nested/center.xhtml @@ -0,0 +1,4 @@ + + + There are 1234 items. + diff --git a/html/src/test/resources/org/kohsuke/stapler/html/IncludesTest/top.xhtml b/html/src/test/resources/org/kohsuke/stapler/html/IncludesTest/top.xhtml new file mode 100644 index 000000000..0191811e2 --- /dev/null +++ b/html/src/test/resources/org/kohsuke/stapler/html/IncludesTest/top.xhtml @@ -0,0 +1,6 @@ + +
+ A prologue. + sample interior text + An epilogue. +
diff --git a/html/src/test/resources/org/kohsuke/stapler/html/StructureTest/standalone.xhtml b/html/src/test/resources/org/kohsuke/stapler/html/StructureTest/standalone.xhtml new file mode 100644 index 000000000..d22d0e994 --- /dev/null +++ b/html/src/test/resources/org/kohsuke/stapler/html/StructureTest/standalone.xhtml @@ -0,0 +1,27 @@ + + + + + XXX #123 + + + +

Error in some component.

+ +

There are no items.

+
+

All items:

+ + + + + + + + + + +
Initial nameInitial value
namevalue
Penultimate namePenultimate value
Final nameFinal value
+
+ + diff --git a/pom.xml b/pom.xml index 5510665b2..70672521f 100644 --- a/pom.xml +++ b/pom.xml @@ -29,6 +29,7 @@ jsp jelly groovy + html