This is an automated email from the ASF dual-hosted git repository.

jamesbognar pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/juneau.git


The following commit(s) were added to refs/heads/master by this push:
     new 378def68d7 Add typed FormDef inputs, per-registry async timeout, and 
markdown expand fields
378def68d7 is described below

commit 378def68d75c4e45b04985468f787e29e329dff7
Author: James Bognar <[email protected]>
AuthorDate: Thu Aug 20 14:44:59 2026 -0400

    Add typed FormDef inputs, per-registry async timeout, and markdown expand 
fields
    
    FormDef now ships typed text/textarea fields painted via createElement 
(never innerHTML)
    so modal submits carry field values. AsyncJobRegistry accepts a 
per-instance hard timeout
    so long agent jobs can exceed the 120s default without raising it globally. 
DetailField
    gains Format.MARKDOWN; the runtime allowlist-copies sanitizing-markdown 
HTML into expand
    slots, and CommonmarkMarkdownRenderer escapes raw HTML and strips unsafe 
URLs.
---
 .../views/markdown/CommonmarkMarkdownRenderer.java |  55 +++++-
 .../server/views/markdown/MarkdownRenderer.java    |   4 +-
 .../markdown/CommonmarkMarkdownRenderer_Test.java  |  41 +++++
 .../juneau/rest/server/views/AsyncJobRegistry.java |  37 +++-
 .../juneau/rest/server/views/DetailField.java      |  74 +++++++-
 .../apache/juneau/rest/server/views/FormDef.java   | 164 +++++++++++++++--
 .../juneau/rest/server/views/RowDetailDef.java     |   5 +-
 .../apache/juneau/rest/server/views/ViewTable.java |  31 +++-
 .../org/apache/juneau/views/juneau-views.css       |  31 ++++
 .../org/apache/juneau/views/juneau-views.js        | 204 ++++++++++++++++++++-
 .../rest/server/views/AsyncJobRegistry_Test.java   |  14 ++
 .../juneau/rest/server/views/DetailField_Test.java |  48 +++++
 .../rest/server/views/ModalDef_FormDef_Test.java   |  48 ++++-
 .../rest/server/views/ModalResult_BrowserTest.java |  20 ++
 .../views/RawContentSink_SecurityScan_Test.java    |  16 +-
 .../views/ViewTable_RowDetail_Emit_Test.java       |  23 +++
 .../server/views/ViewsJs_ModalResult_Test.java     |  51 +++++-
 .../rest/server/views/ViewsJs_RowDetail_Test.java  |  14 ++
 .../src/test/js/modal-result.cjs                   |  50 +++++
 .../src/test/js/row-detail.cjs                     | 139 +++++++++++++-
 20 files changed, 1001 insertions(+), 68 deletions(-)

diff --git 
a/juneau-rest/juneau-rest-server-views-markdown/src/main/java/org/apache/juneau/rest/server/views/markdown/CommonmarkMarkdownRenderer.java
 
b/juneau-rest/juneau-rest-server-views-markdown/src/main/java/org/apache/juneau/rest/server/views/markdown/CommonmarkMarkdownRenderer.java
index 7617a4fadb..d48f31b958 100644
--- 
a/juneau-rest/juneau-rest-server-views-markdown/src/main/java/org/apache/juneau/rest/server/views/markdown/CommonmarkMarkdownRenderer.java
+++ 
b/juneau-rest/juneau-rest-server-views-markdown/src/main/java/org/apache/juneau/rest/server/views/markdown/CommonmarkMarkdownRenderer.java
@@ -22,18 +22,25 @@ import java.util.*;
 
 import org.commonmark.Extension;
 import org.commonmark.ext.gfm.tables.TablesExtension;
+import org.commonmark.node.Node;
 import org.commonmark.parser.Parser;
+import org.commonmark.renderer.html.AttributeProvider;
 import org.commonmark.renderer.html.HtmlRenderer;
 
 /**
  * The default {@link MarkdownRenderer} implementation: a thin wrapper around
  * <a class="doclink" 
href="https://github.com/commonmark/commonmark-java";>commonmark-java</a> with 
the GFM tables
- * extension enabled.
+ * extension enabled, HTML-escaped raw markup, and URL allowlisting on {@code 
<a href>} / {@code <img src>}.
  *
  * <p>
- * The GFM tables extension is on by default because real-world documents 
(runbooks, onboarding guides) routinely
- * use pipe tables, and core CommonMark renders them as paragraphs of literal 
pipes &mdash; shipping core-only would
- * reproduce inside the framework exactly the discovery cost this module 
exists to remove.
+ * The GFM tables extension is on by default because real-world documents 
(runbooks, onboarding guides, skills)
+ * routinely use pipe tables, and core CommonMark renders them as paragraphs 
of literal pipes.
+ *
+ * <p>
+ * Raw HTML in the markdown source is escaped ({@code escapeHtml(true)}), so a 
{@code <script>} or
+ * {@code <img onerror>} in a {@code SKILL.md} becomes visible text, not an 
element.  Link and image URLs are
+ * restricted to {@code http}/{@code https}/{@code mailto}, fragments, and 
scheme-less relative paths;
+ * {@code javascript:}, {@code data:}, and {@code vbscript:} are stripped.
  *
  * <p>
  * commonmark-java's {@link Parser} and {@link HtmlRenderer} are immutable and 
thread-safe once built, so a single
@@ -56,12 +63,17 @@ public class CommonmarkMarkdownRenderer implements 
MarkdownRenderer {
         * Constructor.
         *
         * <p>
-        * Builds a parser/renderer pair with the GFM tables extension enabled 
on both.
+        * Builds a parser/renderer pair with the GFM tables extension enabled, 
raw HTML escaped, and unsafe URLs
+        * stripped.
         */
        public CommonmarkMarkdownRenderer() {
                List<Extension> extensions = List.of(TablesExtension.create());
                parser = Parser.builder().extensions(extensions).build();
-               renderer = 
HtmlRenderer.builder().extensions(extensions).build();
+               renderer = HtmlRenderer.builder()
+                       .extensions(extensions)
+                       .escapeHtml(true)
+                       .attributeProviderFactory(ctx -> new 
SafeUrlAttributeProvider())
+                       .build();
        }
 
        @Override /* MarkdownRenderer */
@@ -70,4 +82,35 @@ public class CommonmarkMarkdownRenderer implements 
MarkdownRenderer {
                        throw iaex("Markdown source must not be null.");
                return renderer.render(parser.parse(markdown));
        }
+
+       /**
+        * Whether {@code url} is safe to emit as an {@code href} or {@code 
src}: {@code http}/{@code https}/
+        * {@code mailto}, a fragment, a same-origin path, or a scheme-less 
relative URL.
+        *
+        * @param url The candidate URL.  May be <jk>null</jk>.
+        * @return <jk>true</jk> if the URL may be copied onto an element.
+        */
+       public static boolean isSafeUrl(String url) {
+               if (url == null || url.isBlank())
+                       return false;
+               var t = url.trim();
+               var lower = t.toLowerCase(Locale.ROOT);
+               if (lower.startsWith("javascript:") || 
lower.startsWith("data:") || lower.startsWith("vbscript:"))
+                       return false;
+               if (lower.startsWith("http://";) || lower.startsWith("https://";) 
|| lower.startsWith("mailto:";))
+                       return true;
+               if (t.charAt(0) == '#' || t.charAt(0) == '/')
+                       return true;
+               return lower.indexOf(':') < 0;
+       }
+
+       private static final class SafeUrlAttributeProvider implements 
AttributeProvider {
+               @Override
+               public void setAttributes(Node node, String tagName, 
Map<String,String> attributes) {
+                       if ("a".equals(tagName) && 
attributes.containsKey("href") && ! isSafeUrl(attributes.get("href")))
+                               attributes.remove("href");
+                       if ("img".equals(tagName) && 
attributes.containsKey("src") && ! isSafeUrl(attributes.get("src")))
+                               attributes.remove("src");
+               }
+       }
 }
diff --git 
a/juneau-rest/juneau-rest-server-views-markdown/src/main/java/org/apache/juneau/rest/server/views/markdown/MarkdownRenderer.java
 
b/juneau-rest/juneau-rest-server-views-markdown/src/main/java/org/apache/juneau/rest/server/views/markdown/MarkdownRenderer.java
index e62bfd4dfe..deeb361844 100644
--- 
a/juneau-rest/juneau-rest-server-views-markdown/src/main/java/org/apache/juneau/rest/server/views/markdown/MarkdownRenderer.java
+++ 
b/juneau-rest/juneau-rest-server-views-markdown/src/main/java/org/apache/juneau/rest/server/views/markdown/MarkdownRenderer.java
@@ -45,7 +45,9 @@ import java.util.*;
  *
  * <p>
  * The rendered HTML is intended to be dropped inside a <c>.jc-prose</c> 
container so it picks up the console's
- * prose typography.
+ * prose typography.  Raw HTML in the source is escaped; {@code javascript:} / 
{@code data:} URLs are stripped.
+ * The row-detail runtime still allowlist-copies the fragment (never {@code 
innerHTML}) when
+ * {@code DetailField.Format.MARKDOWN} is set.
  *
  * <h5 class='section'>See Also:</h5>
  * <ul>
diff --git 
a/juneau-rest/juneau-rest-server-views-markdown/src/test/java/org/apache/juneau/rest/server/views/markdown/CommonmarkMarkdownRenderer_Test.java
 
b/juneau-rest/juneau-rest-server-views-markdown/src/test/java/org/apache/juneau/rest/server/views/markdown/CommonmarkMarkdownRenderer_Test.java
index 4db081af06..f890c5ac57 100644
--- 
a/juneau-rest/juneau-rest-server-views-markdown/src/test/java/org/apache/juneau/rest/server/views/markdown/CommonmarkMarkdownRenderer_Test.java
+++ 
b/juneau-rest/juneau-rest-server-views-markdown/src/test/java/org/apache/juneau/rest/server/views/markdown/CommonmarkMarkdownRenderer_Test.java
@@ -85,4 +85,45 @@ class CommonmarkMarkdownRenderer_Test extends TestBase {
                assertEquals("<p>one</p>\n", RENDERER.toHtml("one"));
                assertEquals("<p>two</p>\n", RENDERER.toHtml("two"));
        }
+
+       
//-----------------------------------------------------------------------------------------------------------------
+       // d) XSS: raw HTML escaped, javascript:/data: URLs stripped
+       
//-----------------------------------------------------------------------------------------------------------------
+
+       @Test void d01_rawScript_isEscapedNotAnElement() {
+               var html = RENDERER.toHtml("hello <script>alert(1)</script>");
+               assertFalse(html.contains("<script"), () -> html);
+               assertTrue(html.contains("&lt;script"), () -> html);
+       }
+
+       @Test void d02_imgOnerror_isEscaped() {
+               var html = RENDERER.toHtml("<img src=x onerror=alert(1)>");
+               assertFalse(html.contains("<img"), () -> html);
+               assertTrue(html.contains("&lt;img"), () -> html);
+       }
+
+       @Test void d03_javascriptHref_isStripped() {
+               var html = RENDERER.toHtml("[x](javascript:alert(1))");
+               assertFalse(html.contains("javascript:"), () -> html);
+               assertTrue(html.contains("<a>") || html.contains("<a >") || 
html.contains("<a>x</a>"), () -> html);
+       }
+
+       @Test void d04_httpsHref_isKept() {
+               var html = RENDERER.toHtml("[y](https://example.com)");
+               assertTrue(html.contains("href=\"https://example.com\"";), () -> 
html);
+       }
+
+       @Test void d05_dataUrl_isRejected() {
+               
assertFalse(CommonmarkMarkdownRenderer.isSafeUrl("data:text/html,<script>"));
+               
assertFalse(CommonmarkMarkdownRenderer.isSafeUrl("javascript:alert(1)"));
+               
assertFalse(CommonmarkMarkdownRenderer.isSafeUrl("vbscript:msg"));
+               assertTrue(CommonmarkMarkdownRenderer.isSafeUrl("https://x";));
+               assertTrue(CommonmarkMarkdownRenderer.isSafeUrl("http://x";));
+               assertTrue(CommonmarkMarkdownRenderer.isSafeUrl("mailto:a@b";));
+               assertTrue(CommonmarkMarkdownRenderer.isSafeUrl("#frag"));
+               
assertTrue(CommonmarkMarkdownRenderer.isSafeUrl("/rest/skills"));
+               
assertTrue(CommonmarkMarkdownRenderer.isSafeUrl("relative/path"));
+               assertFalse(CommonmarkMarkdownRenderer.isSafeUrl(null));
+               assertFalse(CommonmarkMarkdownRenderer.isSafeUrl(""));
+       }
 }
diff --git 
a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/AsyncJobRegistry.java
 
b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/AsyncJobRegistry.java
index f88b192ca2..c1f4bdcd4d 100644
--- 
a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/AsyncJobRegistry.java
+++ 
b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/AsyncJobRegistry.java
@@ -17,6 +17,7 @@
 package org.apache.juneau.rest.server.views;
 
 import static org.apache.juneau.commons.utils.AssertionUtils.*;
+import static org.apache.juneau.commons.utils.Shorts.*;
 
 import java.security.*;
 import java.time.*;
@@ -93,19 +94,40 @@ public final class AsyncJobRegistry implements 
AutoCloseable {
        private final boolean ownsScheduler;
 
        /**
-        * Creates a registry with the production defaults: a system clock, the 
{@link #HARD_TIMEOUT} timeout,
+        * Creates a registry with the production defaults: a system clock, the 
{@link #HARD_TIMEOUT} (120s) timeout,
         * {@link #MAX_OUTPUT_BYTES} / {@link #MAX_SUBSCRIBERS_PER_JOB} caps, 
and a private daemon scheduler that enforces
         * each job's hard timeout.
+        *
+        * <p>
+        * The 120s default is a disclosure bound for jobs that are not 
long-running agent dispatches.  Callers that need
+        * a longer bound (for example a Claude-length create) use {@link 
#AsyncJobRegistry(Duration)} rather than raising
+        * this global default.
+        * </p>
         */
        public AsyncJobRegistry() {
-               this(Clock.systemUTC(), HARD_TIMEOUT, MAX_OUTPUT_BYTES, 
MAX_SUBSCRIBERS_PER_JOB, defaultScheduler(), true);
+               this(HARD_TIMEOUT);
+       }
+
+       /**
+        * Creates a registry with a caller-chosen hard per-job timeout and the 
production caps / scheduler.
+        *
+        * <p>
+        * Use this when a job may run longer than {@link #HARD_TIMEOUT} 
(120s).  The no-arg constructor keeps that
+        * default; this overload does not change it.
+        * </p>
+        *
+        * @param timeout The hard per-job timeout.  Must be a positive 
duration.
+        * @throws IllegalArgumentException If {@code timeout} is 
<jk>null</jk>, zero, or negative.
+        */
+       public AsyncJobRegistry(Duration timeout) {
+               this(Clock.systemUTC(), requirePositiveTimeout(timeout), 
MAX_OUTPUT_BYTES, MAX_SUBSCRIBERS_PER_JOB, defaultScheduler(), true);
        }
 
        /**
         * Test/advanced constructor allowing an injected clock, timeout, caps 
and scheduler.
         *
         * @param clock The clock supplying job-creation and timeout-check 
instants.  Must not be <jk>null</jk>.
-        * @param timeout The hard per-job timeout.  Must not be <jk>null</jk>.
+        * @param timeout The hard per-job timeout.  Must be a positive 
duration.
         * @param maxOutputBytes The per-job streamed-output cap, in bytes.
         * @param maxSubscribers The per-job concurrent-subscriber cap.
         * @param scheduler The scheduler that enforces each job's hard 
timeout, or <jk>null</jk> to rely solely on
@@ -117,13 +139,20 @@ public final class AsyncJobRegistry implements 
AutoCloseable {
 
        private AsyncJobRegistry(Clock clock, Duration timeout, long 
maxOutputBytes, int maxSubscribers, ScheduledExecutorService scheduler, boolean 
ownsScheduler) {
                this.clock = assertArgNotNull("clock", clock);
-               this.timeout = assertArgNotNull("timeout", timeout);
+               this.timeout = requirePositiveTimeout(timeout);
                this.maxOutputBytes = maxOutputBytes;
                this.maxSubscribers = maxSubscribers;
                this.scheduler = scheduler;
                this.ownsScheduler = ownsScheduler;
        }
 
+       private static Duration requirePositiveTimeout(Duration timeout) {
+               assertArgNotNull("timeout", timeout);
+               if (timeout.isZero() || timeout.isNegative())
+                       throw iaex("AsyncJobRegistry timeout must be a positive 
duration, not %s.", timeout);
+               return timeout;
+       }
+
        private static ScheduledExecutorService defaultScheduler() {
                return Executors.newSingleThreadScheduledExecutor(r -> {
                        var t = new Thread(r, "juneau-async-jobs");
diff --git 
a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/DetailField.java
 
b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/DetailField.java
index 34b9996317..48de306e16 100644
--- 
a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/DetailField.java
+++ 
b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/DetailField.java
@@ -1,17 +1,15 @@
 /*
  * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You under the Apache License, Version 2.0
- * (the "License"); you may not use this file except in compliance with
+ * contributor license agreements.  See the NOTICE file distributed with this 
work for additional
+ * information regarding copyright ownership.  The ASF licenses this file to 
You under the Apache
+ * License, Version 2.0 (the "License"); you may not use this file except in 
compliance with
  * the License.  You may obtain a copy of the License at
  *
  *      http://www.apache.org/licenses/LICENSE-2.0
  *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
+ * Unless required by applicable law or agreed to in writing, software 
distributed under the
+ * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 
CONDITIONS OF ANY KIND, either
+ * express or implied.  See the License for the specific language governing 
permissions and
  * limitations under the License.
  */
 package org.apache.juneau.rest.server.views;
@@ -22,19 +20,63 @@ import static org.apache.juneau.commons.utils.Shorts.*;
  * One field slot in a {@link DetailSection}.
  *
  * <p>
- * Values are filled from the expand GET JSON {@code fields} map via {@code 
textContent} only.  There is no
- * {@code render} field in this slice.
+ * Values are filled from the expand GET JSON {@code fields} map.  The default 
{@link Format#TEXT} paints with
+ * {@code textContent} only.  {@link Format#MARKDOWN} stamps {@code 
data-juneau-field-format="markdown"} on the
+ * slot; the runtime copies allowlisted nodes from a {@code DOMParser} 
document and never assigns
+ * {@code innerHTML}.  The expand JSON value for a markdown field is the HTML 
produced by a sanitizing markdown
+ * renderer (see {@code juneau-rest-server-views-markdown}); it is not the raw 
markdown source.
+ *
+ * <p>
+ * This does not bump {@link RowDetailDef#CONTRACT_VERSION}: the expand 
envelope is unchanged, the format
+ * attribute is additive, and a TEXT-only consumer still paints unknown 
attributes via {@code textContent}.
  *
  * @since 10.0.0
  */
 public class DetailField {
 
+       /**
+        * How the expand-JSON scalar is painted into the slot.
+        *
+        * <p>
+        * Each constant carries the lowercase token emitted on {@code 
data-juneau-field-format}.  {@link #TEXT} is
+        * omitted from the template (the default).
+        */
+       public enum Format {
+
+               /** Paint with {@code textContent}.  The default. */
+               TEXT("text"),
+
+               /**
+                * Treat the expand-JSON value as sanitizing-markdown HTML and 
copy allowlisted nodes into the slot.
+                * Never {@code innerHTML}.
+                */
+               MARKDOWN("markdown");
+
+               private final String wire;
+
+               Format(String wire) {
+                       this.wire = wire;
+               }
+
+               /**
+                * Returns the lowercase wire token for this format.
+                *
+                * @return The wire token (e.g. <c>"markdown"</c>).
+                */
+               public String wire() {
+                       return wire;
+               }
+       }
+
        /** The key into the expand JSON {@code fields} map.  Unique across the 
whole {@link RowDetailDef}. */
        public String data;
 
        /** The label shown above the value slot. */
        public String title;
 
+       /** How the slot is painted.  <jk>null</jk> means {@link Format#TEXT}. 
*/
+       public Format format;
+
        /**
         * Creates a field bound to the specified expand-JSON key.
         *
@@ -53,10 +95,22 @@ public class DetailField {
         * Sets the label shown above the value slot.
         *
         * @param value The label.  May be <jk>null</jk> (the {@link #data} key 
is used as a fallback at emit time).
+        *      An empty string suppresses the label (used for a full-width 
markdown body under a section title).
         * @return This object.
         */
        public DetailField title(String value) {
                title = value;
                return this;
        }
+
+       /**
+        * Sets how the expand-JSON scalar is painted into the slot.
+        *
+        * @param value The format.  <jk>null</jk> means {@link Format#TEXT}.
+        * @return This object.
+        */
+       public DetailField format(Format value) {
+               format = value;
+               return this;
+       }
 }
diff --git 
a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/FormDef.java
 
b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/FormDef.java
index 4bb53972bf..af8e1d664e 100644
--- 
a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/FormDef.java
+++ 
b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/FormDef.java
@@ -18,23 +18,31 @@ package org.apache.juneau.rest.server.views;
 
 import static org.apache.juneau.commons.utils.Shorts.*;
 
+import java.util.*;
+
 import org.apache.juneau.commons.bean.*;
 
 /**
- * The declarative source of a row-action modal's input form (the form half of 
{@code TODO-416}; {@code TODO-399}
- * Decision&nbsp;8's "form inside the dialog").
+ * The declarative source of a row-action modal's input form (the form half of 
a {@code present=dialog} action).
  *
- * <h5 class='section'>FreeMarker-first</h5>
+ * <h5 class='section'>Typed inputs, not markup</h5>
+ * <p>
+ * The client paints {@link #fields} as native {@code <input>} / {@code 
<textarea>} elements via
+ * {@code createElement}.  Labels use {@code textContent}; prefills use {@code 
.value}.  The runtime never assigns
+ * {@code innerHTML} from this payload.  Only {@code text} and {@code 
textarea} types are legal &mdash; that is the
+ * XSS bound: hostile prefill cannot become an element.
+ * </p>
  * <p>
- * The form's fields are sourced <b>FreeMarker-template-first</b>, consistent 
with the rest of this module's
- * server-render story: a {@link #template} names the server-side template 
that produces the form's field markup,
- * rendered against the current record.  The alternative bean&rarr;form 
generator ({@code FormDef.of(beanType)}) is a
- * deliberately deferred follow-on ({@code TODO-399} Decision&nbsp;8's 
"bean&rarr;form later"), so this MVP exposes
- * only the template source; the {@code beanType} field is reserved and 
omitted from the wire until then.
+ * {@link #template} may name a server-side template for authors.  It is 
<b>not</b> a client HTML sink; the
+ * shipped {@code juneau-views.js} ignores it.
+ * </p>
  *
  * <h5 class='section'>Example:</h5>
  * <p class='bjava'>
- *     FormDef <jv>form</jv> = 
FormDef.<jsm>ofTemplate</jsm>(<js>"servlet:/incidents/ack-form.ftl"</js>);
+ *     FormDef <jv>form</jv> = FormDef.<jsm>create</jsm>()
+ *             .field(FormDef.Input.<jsm>of</jsm>(<js>"resolution"</js>, 
<js>"Resolution comment"</js>, <js>"textarea"</js>)
+ *                     .required()
+ *                     .value(<js>""</js>));
  * </p>
  *
  * <h5 class='section'>See Also:</h5>
@@ -45,13 +53,111 @@ import org.apache.juneau.commons.bean.*;
  *
  * @since 10.0.0
  */
-@BeanType(properties="template")
-@SuppressWarnings("java:S1845") // Fluent-builder setters intentionally mirror 
field names (Juneau DSL convention).
+@BeanType(properties="template,fields")
+@SuppressWarnings({
+       "java:S1845" // Fluent-builder setters intentionally mirror field names 
(Juneau DSL convention).
+})
 public class FormDef {
 
-       /** The FreeMarker template reference that renders the form's field 
markup against the current record. */
+       /**
+        * A single typed form input painted client-side with {@code 
createElement} (never {@code innerHTML}).
+        *
+        * @since 10.0.0
+        */
+       @BeanType(properties="name,label,type,required,value")
+       @SuppressWarnings({
+               "java:S1845" // Fluent-builder setters intentionally mirror 
field names (Juneau DSL convention).
+       })
+       public static class Input {
+
+               /** The submit-body key for this field (e.g. {@code 
resolution}). */
+               public String name;
+
+               /** The label shown next to the control. */
+               public String label;
+
+               /** {@code text} or {@code textarea}. */
+               public String type;
+
+               /** When {@link Boolean#TRUE}, the control is required.  
Omitted from the wire otherwise. */
+               public Boolean required;
+
+               /** Optional prefill, applied via {@code .value} (never {@code 
innerHTML}). */
+               public String value;
+
+               /**
+                * Creates a typed form input.
+                *
+                * @param name The submit-body key.  Must not be <jk>null</jk> 
or blank.
+                * @param label The visible label.  Must not be <jk>null</jk> 
or blank.
+                * @param type {@code text} or {@code textarea}.  <jk>null</jk> 
or blank defaults to {@code text}.
+                * @return A new {@link Input}.
+                * @throws IllegalArgumentException If {@code name} or {@code 
label} is blank, or {@code type} is not an
+                *      allowed token.
+                */
+               public static Input of(String name, String label, String type) {
+                       if (name == null || name.isBlank())
+                               throw iaex("FormDef.Input name must not be null 
or blank.");
+                       if (label == null || label.isBlank())
+                               throw iaex("FormDef.Input label must not be 
null or blank.");
+                       var t = (type == null || type.isBlank()) ? "text" : 
type;
+                       if (! "text".equals(t) && ! "textarea".equals(t))
+                               throw iaex("FormDef.Input type must be 'text' 
or 'textarea', not '%s'.", t);
+                       var i = new Input();
+                       i.name = name;
+                       i.label = label;
+                       i.type = t;
+                       return i;
+               }
+
+               /**
+                * Marks this input required.
+                *
+                * @return This object.
+                */
+               public Input required() {
+                       required = Boolean.TRUE;
+                       return this;
+               }
+
+               /**
+                * Sets whether this input is required.
+                *
+                * @param value <jk>true</jk> to require a value; 
<jk>false</jk> omits the flag from the wire.
+                * @return This object.
+                */
+               public Input required(boolean value) {
+                       required = value ? Boolean.TRUE : null;
+                       return this;
+               }
+
+               /**
+                * Sets the optional prefill.
+                *
+                * @param value The prefill.  Can be <jk>null</jk> to unset.
+                * @return This object.
+                */
+               public Input value(String value) {
+                       this.value = value;
+                       return this;
+               }
+       }
+
+       /** Optional FreeMarker template reference for server authors; ignored 
by the client. */
        public String template;
 
+       /** Typed inputs in display order; omitted from the wire when none are 
declared. */
+       public List<Input> fields;
+
+       /**
+        * Starts an empty form (add {@link #field(Input) fields} and/or a 
{@link #template(String) template}).
+        *
+        * @return A new {@link FormDef}.
+        */
+       public static FormDef create() {
+               return new FormDef();
+       }
+
        /**
         * Creates a form sourced from the specified FreeMarker template 
reference.
         *
@@ -60,10 +166,36 @@ public class FormDef {
         * @throws IllegalArgumentException If {@code template} is 
<jk>null</jk> or blank.
         */
        public static FormDef ofTemplate(String template) {
-               if (template == null || template.isBlank())
+               return create().template(template);
+       }
+
+       /**
+        * Sets the optional server-author template reference.  The client 
never treats this as HTML.
+        *
+        * @param value The template reference.  Must not be <jk>null</jk> or 
blank.
+        * @return This object.
+        * @throws IllegalArgumentException If {@code value} is <jk>null</jk> 
or blank.
+        */
+       public FormDef template(String value) {
+               if (value == null || value.isBlank())
                        throw iaex("FormDef template must not be null or 
blank.");
-               var f = new FormDef();
-               f.template = template;
-               return f;
+               template = value;
+               return this;
+       }
+
+       /**
+        * Adds one typed input field.
+        *
+        * @param value The input.  Must not be <jk>null</jk>.
+        * @return This object.
+        * @throws IllegalArgumentException If {@code value} is <jk>null</jk>.
+        */
+       public FormDef field(Input value) {
+               if (value == null)
+                       throw iaex("FormDef field must not be null.");
+               if (fields == null)
+                       fields = l();
+               fields.add(value);
+               return this;
        }
 }
diff --git 
a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RowDetailDef.java
 
b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RowDetailDef.java
index 870b889fd9..d28a445eea 100644
--- 
a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RowDetailDef.java
+++ 
b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RowDetailDef.java
@@ -28,8 +28,9 @@ import org.apache.juneau.rest.server.widgets.*;
  *
  * <p>
  * Structure is emitted as a {@code <template data-juneau-row-detail>} sibling 
of the view table; field values
- * arrive via a same-origin GET and are painted with {@code textContent} only. 
 This type is Java-only &mdash; it
- * is not part of the {@code VIEW_META} JSON sidecar.
+ * arrive via a same-origin GET.  {@link DetailField.Format#TEXT} (the 
default) paints with {@code textContent};
+ * {@link DetailField.Format#MARKDOWN} copies allowlisted nodes from a {@code 
DOMParser} document and never
+ * assigns {@code innerHTML}.  This type is Java-only &mdash; it is not part 
of the {@code VIEW_META} JSON sidecar.
  *
  * @since 10.0.0
  */
diff --git 
a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ViewTable.java
 
b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ViewTable.java
index 3a9d61ed60..815e22b342 100644
--- 
a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ViewTable.java
+++ 
b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ViewTable.java
@@ -146,6 +146,12 @@ public class ViewTable {
        /** Attribute carrying a {@link DetailField#data} key on each empty 
field slot. */
        public static final String DETAIL_FIELD_ATTR = "data-juneau-field";
 
+       /**
+        * Attribute carrying a {@link DetailField.Format} wire token.  Omitted 
for {@link DetailField.Format#TEXT}
+        * (the default).
+        */
+       public static final String DETAIL_FIELD_FORMAT_ATTR = 
"data-juneau-field-format";
+
        /** Attribute carrying an {@link 
org.apache.juneau.rest.server.widgets.ActionRef} id on a write button. */
        public static final String DETAIL_ACTION_ATTR = "data-juneau-action";
 
@@ -482,11 +488,7 @@ public class ViewTable {
                        var fieldSlots = new ArrayList<>();
                        if (s.fields != null) {
                                for (var f : s.fields) {
-                                       fieldSlots.add(div(
-                                               div(f.title == null || 
f.title.isBlank() ? f.data : f.title)
-                                                       
.class_("juneau-view-detail-field-title"),
-                                               div().attr(DETAIL_FIELD_ATTR, 
f.data).class_("juneau-view-detail-field-value")
-                                       ).class_("juneau-view-detail-field"));
+                                       fieldSlots.add(emitDetailField(f));
                                }
                        }
                        kids.add(div(fieldSlots.toArray())
@@ -503,6 +505,25 @@ public class ViewTable {
                        .children(sections.toArray());
        }
 
+       private static Div emitDetailField(DetailField f) {
+               var markdown = f.format == DetailField.Format.MARKDOWN;
+               var valueSlot = div().attr(DETAIL_FIELD_ATTR, f.data);
+               if (markdown) {
+                       valueSlot.attr(DETAIL_FIELD_FORMAT_ATTR, 
DetailField.Format.MARKDOWN.wire());
+                       valueSlot.class_("juneau-view-detail-field-value 
juneau-view-detail-markdown jc-prose");
+               } else {
+                       valueSlot.class_("juneau-view-detail-field-value");
+               }
+               var hideTitle = markdown && f.title != null && 
f.title.isEmpty();
+               if (hideTitle)
+                       return div(valueSlot).class_("juneau-view-detail-field 
juneau-view-detail-field-markdown");
+               var label = f.title == null || f.title.isBlank() ? f.data : 
f.title;
+               return div(
+                       div(label).class_("juneau-view-detail-field-title"),
+                       valueSlot
+               ).class_(markdown ? "juneau-view-detail-field 
juneau-view-detail-field-markdown" : "juneau-view-detail-field");
+       }
+
        private static Div 
emitActionBar(org.apache.juneau.rest.server.widgets.ActionBar bar, 
List<RowAction> rowActions) {
                var buttons = new ArrayList<>();
                for (var item : bar.items) {
diff --git 
a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-views.css
 
b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-views.css
index d9d62cf856..8f710514dd 100644
--- 
a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-views.css
+++ 
b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-views.css
@@ -448,6 +448,14 @@ table.dataTable > tbody > tr > td {
        font-weight: 600;
 }
 
+.juneau-view-detail-field-markdown {
+       grid-column: 1 / -1;
+}
+
+.juneau-view-detail-markdown {
+       max-width: 96ch;
+}
+
 .juneau-view-detail-status {
        margin: 0.5em 0 0;
 }
@@ -499,6 +507,29 @@ table.dataTable > tbody > tr > td {
        margin: 0 0 0.35em 42%;
 }
 
+.juneau-view-dialog-form {
+       margin: 0 0 1em;
+       display: flex;
+       flex-direction: column;
+       gap: 0.75em;
+}
+
+.juneau-view-dialog-form-row label {
+       display: block;
+       font-weight: 600;
+       margin-bottom: 0.25em;
+}
+
+.juneau-view-dialog-form-row input,
+.juneau-view-dialog-form-row textarea {
+       width: 100%;
+       box-sizing: border-box;
+}
+
+.juneau-view-dialog-form-row textarea {
+       min-height: 6em;
+}
+
 .juneau-view-dialog-actions {
        display: flex;
        gap: 0.5em;
diff --git 
a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-views.js
 
b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-views.js
index 7600fa4770..37a4e7d23b 100644
--- 
a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-views.js
+++ 
b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-views.js
@@ -966,16 +966,129 @@
        }
 
        /**
-        * Fills `[data-juneau-field]` slots from an expand JSON `fields` map 
via textContent only.  Unknown keys are
-        * dropped; missing keys and non-scalars become empty.
+        * Tag names (uppercase) the markdown slot copier will create.  
Everything else is unwrapped (children kept)
+        * except the drop-with-children set in {@link #fillMarkdownSlot}.
+        */
+       const MARKDOWN_ALLOWED_TAGS = {
+               P: 1, BR: 1, H1: 1, H2: 1, H3: 1, H4: 1, H5: 1, H6: 1,
+               UL: 1, OL: 1, LI: 1, PRE: 1, CODE: 1, EM: 1, STRONG: 1, A: 1, 
BLOCKQUOTE: 1, HR: 1,
+               TABLE: 1, THEAD: 1, TBODY: 1, TR: 1, TH: 1, TD: 1, DEL: 1, SUP: 
1, SUB: 1
+       };
+
+       /** Tags whose children are discarded, not unwrapped (script body must 
not become text). */
+       const MARKDOWN_DROP_TAGS = {
+               SCRIPT: 1, STYLE: 1, IFRAME: 1, OBJECT: 1, EMBED: 1, LINK: 1, 
META: 1, BASE: 1, FORM: 1, INPUT: 1, SVG: 1, IMG: 1
+       };
+
+       /**
+        * Whether `href` is safe to copy onto an {@code <a>}: http(s), mailto, 
same-origin path, fragment, or a
+        * scheme-less relative URL.  javascript:/data:/vbscript: and other 
schemes are rejected.
+        */
+       function isSafeMarkdownHref(href) {
+               if (href == null) return false;
+               const t = String(href).trim();
+               if (!t) return false;
+               const lower = t.toLowerCase();
+               if (lower.startsWith("javascript:") || 
lower.startsWith("data:") || lower.startsWith("vbscript:"))
+                       return false;
+               if (lower.startsWith("http://";) || lower.startsWith("https://";) 
|| lower.startsWith("mailto:";))
+                       return true;
+               if (t.charAt(0) === "#" || t.charAt(0) === "/")
+                       return true;
+               return lower.indexOf(":") < 0;
+       }
+
+       function clearElementChildren(el) {
+               if (!el) return;
+               if (typeof el.replaceChildren === "function") {
+                       el.replaceChildren();
+                       return;
+               }
+               while (el.firstChild)
+                       el.removeChild(el.firstChild);
+       }
+
+       function copyAllowedMarkdownAttrs(from, to) {
+               if (!from || !to || typeof to.setAttribute !== "function") 
return;
+               if (from.tagName === "A") {
+                       const href = typeof from.getAttribute === "function" ? 
from.getAttribute("href") : null;
+                       if (isSafeMarkdownHref(href))
+                               to.setAttribute("href", href);
+               }
+       }
+
+       function copySanitizedMarkdownChildren(from, to, doc) {
+               if (!from || !to || !from.childNodes) return;
+               const kids = from.childNodes;
+               for (let i = 0; i < kids.length; i++) {
+                       const n = kids[i];
+                       if (!n) continue;
+                       if (n.nodeType === 3) {
+                               const text = n.nodeValue == null ? "" : 
String(n.nodeValue);
+                               if (doc && typeof doc.createTextNode === 
"function")
+                                       
to.appendChild(doc.createTextNode(text));
+                               continue;
+                       }
+                       if (n.nodeType !== 1) continue;
+                       const tag = n.tagName ? String(n.tagName).toUpperCase() 
: "";
+                       if (MARKDOWN_DROP_TAGS[tag])
+                               continue;
+                       if (!MARKDOWN_ALLOWED_TAGS[tag]) {
+                               copySanitizedMarkdownChildren(n, to, doc);
+                               continue;
+                       }
+                       const dest = doc.createElement(tag.toLowerCase());
+                       copyAllowedMarkdownAttrs(n, dest);
+                       copySanitizedMarkdownChildren(n, dest, doc);
+                       to.appendChild(dest);
+               }
+       }
+
+       /**
+        * Paints a markdown field slot from sanitizing-markdown HTML.  Parses 
with {@code DOMParser} (does not
+        * execute script) and copies allowlisted nodes via {@code 
createElement}/{@code createTextNode}.  Never
+        * assigns {@code innerHTML}.  Missing {@code DOMParser} fails closed 
to {@code textContent}.
+        */
+       function fillMarkdownSlot(el, html) {
+               clearElementChildren(el);
+               if (html == null || html === "") return;
+               const src = String(html);
+               const doc = typeof document !== "undefined" ? document : null;
+               const Parser = typeof DOMParser !== "undefined" ? DOMParser
+                       : (typeof window !== "undefined" ? window.DOMParser : 
null);
+               if (!Parser || !doc || typeof doc.createElement !== "function") 
{
+                       el.textContent = src;
+                       return;
+               }
+               let parsed;
+               try {
+                       parsed = new Parser().parseFromString("<div>" + src + 
"</div>", "text/html");
+               } catch (e) {
+                       el.textContent = src;
+                       return;
+               }
+               const wrap = parsed && parsed.body && parsed.body.firstChild;
+               if (!wrap) return;
+               copySanitizedMarkdownChildren(wrap, el, doc);
+       }
+
+       /**
+        * Fills `[data-juneau-field]` slots from an expand JSON `fields` map.  
TEXT slots (the default, and any
+        * unknown format) use textContent only.  {@code 
data-juneau-field-format="markdown"} slots use
+        * fillMarkdownSlot.  Unknown keys are dropped; missing keys and 
non-scalars become empty.
         */
        function fillDetailSlots(root, fields) {
                if (!root || !root.querySelectorAll) return;
                const map = fields && typeof fields === "object" ? fields : {};
                const slots = root.querySelectorAll("[data-juneau-field]");
                for (let i = 0; i < slots.length; i++) {
-                       const key = slots[i].getAttribute("data-juneau-field");
-                       slots[i].textContent = Object.hasOwn(map, key) ? 
scalarFieldValue(map[key]) : "";
+                       const slot = slots[i];
+                       const key = slot.getAttribute("data-juneau-field");
+                       const value = Object.hasOwn(map, key) ? 
scalarFieldValue(map[key]) : "";
+                       if (slot.getAttribute("data-juneau-field-format") === 
"markdown")
+                               fillMarkdownSlot(slot, value);
+                       else
+                               slot.textContent = value;
                }
        }
 
@@ -1845,6 +1958,8 @@
                        dialog.appendChild(dl);
                }
 
+               appendDialogForm(dialog, modal && modal.form);
+
                const actions = document.createElement("div");
                actions.className = "juneau-view-dialog-actions";
                const cancelBtn = document.createElement("button");
@@ -1863,6 +1978,69 @@
                return { backdrop: backdrop, dialog: dialog, confirmBtn: 
confirmBtn, cancelBtn: cancelBtn };
        }
 
+       /** Whether `type` is a legal FormDef input token.  Anything else is 
skipped (typed inputs only). */
+       function isTypedFormInputType(type) {
+               return type === "text" || type === "textarea";
+       }
+
+       /**
+        * Paints typed form fields as native label+input/textarea controls via 
createElement.  Labels use textContent;
+        * prefills use `.value`.  Never innerHTML, never a template-markup 
sink (that FormDef field is a server-author
+        * reference).  Unknown types are skipped so a hostile type token 
cannot become an element.
+        */
+       function appendDialogForm(dialog, form) {
+               if (!dialog || !form || !form.fields || !form.fields.length) 
return;
+               const wrap = document.createElement("div");
+               wrap.className = "juneau-view-dialog-form";
+               wrap.setAttribute("data-testid", "dialog-form");
+               form.fields.forEach(function (f) {
+                       if (!f || f.name == null || String(f.name) === "") 
return;
+                       const type = (f.type == null || f.type === "") ? "text" 
: String(f.type);
+                       if (!isTypedFormInputType(type)) return;
+                       const row = document.createElement("div");
+                       row.className = "juneau-view-dialog-form-row";
+                       const id = "juneau-dialog-field-" + String(f.name);
+                       const label = document.createElement("label");
+                       label.setAttribute("for", id);
+                       label.textContent = f.label != null ? String(f.label) : 
String(f.name);
+                       const input = type === "textarea" ? 
document.createElement("textarea") : document.createElement("input");
+                       if (type !== "textarea") input.type = "text";
+                       input.id = id;
+                       input.name = String(f.name);
+                       input.setAttribute("data-juneau-form-field", 
String(f.name));
+                       if (f.required) input.required = true;
+                       if (f.value != null) input.value = String(f.value);
+                       row.appendChild(label);
+                       row.appendChild(input);
+                       wrap.appendChild(row);
+               });
+               if (wrap.childNodes.length)
+                       dialog.appendChild(wrap);
+       }
+
+       /**
+        * Reads typed form controls from a dialog via `.value` (never 
textContent of the control, never innerHTML)
+        * into a `{ name: value }` map for the submit body.
+        */
+       function collectDialogFormFields(dialog) {
+               const out = {};
+               if (!dialog || !dialog.querySelectorAll) return out;
+               const nodes = 
dialog.querySelectorAll("[data-juneau-form-field]");
+               for (let i = 0; i < nodes.length; i++) {
+                       const el = nodes[i];
+                       const name = el.getAttribute ? 
el.getAttribute("data-juneau-form-field") : null;
+                       if (name == null || name === "") continue;
+                       const tag = el.tagName ? 
String(el.tagName).toLowerCase() : "";
+                       if (tag !== "input" && tag !== "textarea") continue;
+                       if (tag === "input") {
+                               const itype = el.type ? 
String(el.type).toLowerCase() : "text";
+                               if (itype !== "text") continue;
+                       }
+                       out[name] = el.value != null ? String(el.value) : "";
+               }
+               return out;
+       }
+
        /** Shows a dialog overlay for an action and wires its confirm (submit) 
/ cancel (dismiss) buttons. */
        function showActionDialog(modal, action, table, tr, ctx) {
                const ui = buildDialogOverlay(modal, action);
@@ -1872,8 +2050,9 @@
                }
                ui.cancelBtn.addEventListener("click", close);
                ui.confirmBtn.addEventListener("click", function () {
+                       const fields = collectDialogFormFields(ui.dialog);
                        close();
-                       submitActionDialog(modal, action, table, tr, ctx);
+                       submitActionDialog(modal, action, table, tr, ctx, 
fields);
                });
                if (ctx) ctx._actionDialog = ui.backdrop;
                document.body.appendChild(ui.backdrop);
@@ -1883,14 +2062,18 @@
        /**
         * Issues the dialog's non-safe submit, carrying the server-minted 
idempotency key and the row's targetId so the
         * server can check the key's `(action, targetId)` binding (HIGH-8) - a 
double-click / re-submit / browser retry
-        * therefore all carry the SAME key.  Delegates the fail-closed CSRF 
submit + in-flight marker + typed-result
-        * settling to submitRowAction(...).
+        * therefore all carry the SAME key.  Typed FormDef values collected 
via `.value` ride in `extra.fields`.
+        * Delegates the fail-closed CSRF submit + in-flight marker + 
typed-result settling to submitRowAction(...).
         */
-       function submitActionDialog(modal, action, table, tr, ctx) {
+       function submitActionDialog(modal, action, table, tr, ctx, fields) {
                const extra = {};
                const targetId = (tr && tr.getAttribute) ? 
tr.getAttribute("data-juneau-row-id") : null;
                if (targetId != null) extra.targetId = targetId;
                if (modal && modal.idempotencyKey != null) extra.idempotencyKey 
= modal.idempotencyKey;
+               if (fields && typeof fields === "object") {
+                       const keys = Object.keys(fields);
+                       if (keys.length) extra.fields = fields;
+               }
                submitRowAction(action, table, tr, ctx, extra);
        }
 
@@ -2384,6 +2567,8 @@
                isSafeDetailUrl: isSafeDetailUrl,
                substituteDetailUrl: substituteDetailUrl,
                scalarFieldValue: scalarFieldValue,
+               isSafeMarkdownHref: isSafeMarkdownHref,
+               fillMarkdownSlot: fillMarkdownSlot,
                fillDetailSlots: fillDetailSlots,
                findRowDetailTemplate: findRowDetailTemplate,
                detailCoalesceKey: detailCoalesceKey,
@@ -2417,6 +2602,9 @@
                renderActionOutcome: renderActionOutcome,
                openActionDialog: openActionDialog,
                buildDialogOverlay: buildDialogOverlay,
+               appendDialogForm: appendDialogForm,
+               collectDialogFormFields: collectDialogFormFields,
+               isTypedFormInputType: isTypedFormInputType,
                showActionDialog: showActionDialog,
                submitActionDialog: submitActionDialog,
                // Async jobs + SSE streaming (TODO-425) - exposed for the 
canary and manual verification.  The job-running
diff --git 
a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/AsyncJobRegistry_Test.java
 
b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/AsyncJobRegistry_Test.java
index 78000040be..acc74b9b89 100644
--- 
a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/AsyncJobRegistry_Test.java
+++ 
b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/AsyncJobRegistry_Test.java
@@ -190,4 +190,18 @@ class AsyncJobRegistry_Test extends TestBase {
                        assertEquals(120, 
AsyncJobRegistry.HARD_TIMEOUT.getSeconds());
                }
        }
+
+       @Test void h02_publicTimeoutConstructorHonorsLongerDuration() {
+               try (var r = new AsyncJobRegistry(Duration.ofMinutes(10))) {
+                       var job = r.create();
+                       assertEquals(Duration.ofMinutes(10), 
Duration.between(job.createdAt(), job.deadline()));
+                       assertEquals(120, 
AsyncJobRegistry.HARD_TIMEOUT.getSeconds(), "global default must stay 120s");
+               }
+       }
+
+       @Test void h03_publicTimeoutConstructorRejectsNonPositive() {
+               assertThrows(IllegalArgumentException.class, () -> new 
AsyncJobRegistry((Duration) null));
+               assertThrows(IllegalArgumentException.class, () -> new 
AsyncJobRegistry(Duration.ZERO));
+               assertThrows(IllegalArgumentException.class, () -> new 
AsyncJobRegistry(Duration.ofSeconds(-1)));
+       }
 }
diff --git 
a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/DetailField_Test.java
 
b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/DetailField_Test.java
new file mode 100644
index 0000000000..f9dc760712
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/DetailField_Test.java
@@ -0,0 +1,48 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.views;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.apache.juneau.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * {@link DetailField} factory, fluent setters, and {@link DetailField.Format} 
wire tokens.
+ */
+class DetailField_Test extends TestBase {
+
+       @Test void a01_of_setsData() {
+               var f = DetailField.of("body");
+               assertEquals("body", f.data);
+               assertNull(f.title);
+               assertNull(f.format);
+       }
+
+       @Test void a02_of_blankRejected() {
+               assertThrows(IllegalArgumentException.class, () -> 
DetailField.of(""));
+               assertThrows(IllegalArgumentException.class, () -> 
DetailField.of(null));
+       }
+
+       @Test void a03_fluentFormatAndTitle() {
+               var f = 
DetailField.of("body").title("SKILL.md").format(DetailField.Format.MARKDOWN);
+               assertEquals("SKILL.md", f.title);
+               assertEquals(DetailField.Format.MARKDOWN, f.format);
+               assertEquals("markdown", f.format.wire());
+               assertEquals("text", DetailField.Format.TEXT.wire());
+       }
+}
diff --git 
a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ModalDef_FormDef_Test.java
 
b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ModalDef_FormDef_Test.java
index f7a54274de..a669fc8dcd 100644
--- 
a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ModalDef_FormDef_Test.java
+++ 
b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ModalDef_FormDef_Test.java
@@ -29,9 +29,9 @@ import org.junit.jupiter.api.*;
  * confirmation fetch returns (design doc ยง6.2; the modal/form half of {@code 
TODO-416}).
  *
  * <p>
- * The confirmation body is typed structured fields painted client-side with 
{@code textContent} (never
- * {@code innerHTML}); this test pins that field shape and the 
FreeMarker-first form source, plus the omit-when-unset
- * rule for the optional form and idempotency key.
+ * The confirmation body is typed fields painted with textContent (never 
innerHTML); this test pins that field
+ * shape, the typed FormDef inputs the client paints (never template markup), 
plus the omit-when-unset rule for
+ * the optional form and idempotency key.
  */
 class ModalDef_FormDef_Test extends TestBase {
 
@@ -113,4 +113,46 @@ class ModalDef_FormDef_Test extends TestBase {
                assertTrue(e.getMessage().contains("blank"), e::getMessage);
                assertThrows(IllegalArgumentException.class, () -> 
FormDef.ofTemplate(null));
        }
+
+       @Test void c04_formDef_serializesTypedInputs() {
+               var form = FormDef.create()
+                       .field(FormDef.Input.of("resolution", "Resolution 
comment", "textarea").required().value("done"));
+               var json = Json.of(form);
+               var expected = Json.to("""
+                       {"fields":[{"name":"resolution","label":"Resolution 
comment","type":"textarea","required":true,"value":"done"}]}
+                       """, Map.class);
+               assertEquals(expected, Json.to(json, Map.class), json);
+               assertFalse(json.contains("\"template\""), json);
+       }
+
+       @Test void c05_formDef_textDefaultAndOptionalPrefillOmitted() {
+               var json = 
Json.of(FormDef.create().field(FormDef.Input.of("note", "Note", null)));
+               assertTrue(json.contains("\"type\":\"text\""), json);
+               assertFalse(json.contains("\"required\""), json);
+               assertFalse(json.contains("\"value\""), json);
+       }
+
+       @Test void c06_formDef_inputBlankNameOrLabelThrows() {
+               assertThrows(IllegalArgumentException.class, () -> 
FormDef.Input.of("  ", "L", "text"));
+               assertThrows(IllegalArgumentException.class, () -> 
FormDef.Input.of(null, "L", "text"));
+               assertThrows(IllegalArgumentException.class, () -> 
FormDef.Input.of("n", "  ", "text"));
+               assertThrows(IllegalArgumentException.class, () -> 
FormDef.Input.of("n", null, "text"));
+       }
+
+       @Test void c07_formDef_unknownTypeThrows() {
+               var e = assertThrows(IllegalArgumentException.class, () -> 
FormDef.Input.of("n", "L", "password"));
+               assertTrue(e.getMessage().contains("text"), e::getMessage);
+               assertThrows(IllegalArgumentException.class, () -> 
FormDef.Input.of("n", "L", "<img>"));
+       }
+
+       @Test void c08_formDef_nullFieldThrows() {
+               assertThrows(IllegalArgumentException.class, () -> 
FormDef.create().field(null));
+       }
+
+       @Test void c09_formDef_templateAndFieldsTogether() {
+               var json = Json.of(FormDef.ofTemplate("servlet:/x.ftl")
+                       .field(FormDef.Input.of("resolution", "Resolution", 
"textarea")));
+               assertTrue(json.contains("\"template\":\"servlet:/x.ftl\""), 
json);
+               assertTrue(json.contains("\"name\":\"resolution\""), json);
+       }
 }
diff --git 
a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ModalResult_BrowserTest.java
 
b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ModalResult_BrowserTest.java
index cc497eaf4a..56448dcf1b 100644
--- 
a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ModalResult_BrowserTest.java
+++ 
b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ModalResult_BrowserTest.java
@@ -228,4 +228,24 @@ class ModalResult_BrowserTest extends TestBase {
                assertTrue(body.contains("ack"), () -> "action id not carried 
on submit: " + report);
                assertEquals(Boolean.TRUE, m.get("backdropClosedAfterConfirm"), 
() -> "modal did not close on confirm: " + report);
        }
+
+       @Test void e04_formDefInputsPaintTypedControlsAndSubmitFieldValues() {
+               var f = sub("form");
+               assertEquals(Boolean.TRUE, f.get("formVisible"), () -> "FormDef 
inputs not visible: " + report);
+               assertEquals("<img src=x onerror=alert(1)>", 
f.get("textareaPrefill"), () -> report.toString());
+               assertEquals("ok", f.get("notePrefill"), () -> 
report.toString());
+               assertEquals(Boolean.TRUE, f.get("passwordSkipped"), () -> 
"non-text type was painted: " + report);
+               assertEquals(0L, ((Number) 
f.get("injectedImgCount")).longValue(),
+                       () -> "hostile prefill became an element: " + report);
+               assertEquals(Boolean.TRUE, f.get("templateNotInFormHtml"), () 
-> "form.template was used as markup: " + report);
+               assertEquals(Boolean.TRUE, f.get("submitIssued"), () -> 
"confirm did not submit: " + report);
+               var body = String.valueOf(f.get("submitBody"));
+               assertTrue(body.contains("key-close"), () -> "idempotency key 
missing: " + report);
+               assertTrue(body.contains("QABCDEF"), () -> "target id missing: 
" + report);
+               assertTrue(body.contains("fixed in change"), () -> "edited 
textarea value missing: " + report);
+               assertTrue(body.contains("\"fields\""), () -> "fields object 
missing: " + report);
+               assertTrue(body.contains("resolution"), () -> 
report.toString());
+               assertFalse(body.contains("skipme"), () -> "skipped type leaked 
into submit: " + report);
+               assertFalse(body.contains("secret"), () -> "skipped type value 
leaked: " + report);
+       }
 }
diff --git 
a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RawContentSink_SecurityScan_Test.java
 
b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RawContentSink_SecurityScan_Test.java
index d70df13f59..6206619236 100644
--- 
a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RawContentSink_SecurityScan_Test.java
+++ 
b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RawContentSink_SecurityScan_Test.java
@@ -334,17 +334,29 @@ class RawContentSink_SecurityScan_Test extends TestBase {
                var root = requireModuleRoot();
                var rel = Path.of("src", "main", "resources", "org", "apache", 
"juneau", "views", "juneau-views.js");
                var source = Files.readString(root.resolve(rel));
-               var needle = "slots[i].textContent = Object.hasOwn(map, key) ? 
scalarFieldValue(map[key]) : \"\";";
+               var needle = "slot.textContent = value;";
                assertTrue(source.contains(needle), "fillDetailSlots assignment 
moved - update this fixture");
                var fn = extractFunction(source, "function fillDetailSlots(");
                var clean = 
RawContentSinkScanner.scanJsHtmlSinks(rel.toString(), fn);
                assertEquals(List.of(), clean.violations());
-               var mutated = fn.replace("slots[i].textContent", 
"slots[i].innerHTML");
+               var mutated = fn.replace("slot.textContent", "slot.innerHTML");
                var mutatedResult = 
RawContentSinkScanner.scanJsHtmlSinks(rel.toString(), mutated);
                assertTrue(mutatedResult.violations().size() >= 1,
                        () -> "mutating fillDetailSlots to innerHTML must be 
flagged: " + mutatedResult.violations());
        }
 
+       @Test void d04_fillMarkdownSlot_hasNoInnerHtml() throws Exception {
+               var root = requireModuleRoot();
+               var rel = Path.of("src", "main", "resources", "org", "apache", 
"juneau", "views", "juneau-views.js");
+               var source = Files.readString(root.resolve(rel));
+               var fn = extractFunction(source, "function fillMarkdownSlot(");
+               assertTrue(fn.contains("DOMParser"), fn);
+               assertTrue(fn.contains("createElement"), fn);
+               assertFalse(fn.contains("innerHTML"), fn);
+               var r = RawContentSinkScanner.scanJsHtmlSinks(rel.toString(), 
fn);
+               assertEquals(List.of(), r.violations(), () -> "fillMarkdownSlot 
must not assign innerHTML: " + r.violations());
+       }
+
        private static String extractFunction(String body, String signature) {
                var start = body.indexOf(signature);
                assertTrue(start >= 0, () -> "'" + signature + "' not found");
diff --git 
a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewTable_RowDetail_Emit_Test.java
 
b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewTable_RowDetail_Emit_Test.java
index 94e8c6017a..44745b7f27 100644
--- 
a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewTable_RowDetail_Emit_Test.java
+++ 
b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewTable_RowDetail_Emit_Test.java
@@ -113,4 +113,27 @@ class ViewTable_RowDetail_Emit_Test extends TestBase {
                assertTrue(html.contains("data-juneau-row-detail"), html);
                
assertTrue(html.contains("data-juneau-detail-section=\"overview\""), html);
        }
+
+       @Test void a08_markdownFormat_stampsAttribute_textDoesNot() {
+               var v = ViewDef.create("skills")
+                       .dataMode(DataMode.CLIENT)
+                       .dataUrl("/data")
+                       .columns(Column.of("id").title("Id"))
+                       .details(RowDetailDef.create()
+                               .endpoint("/data/{id}")
+                               .sections(DetailSection.create("body", 
"SKILL.md")
+                                       .columns(1)
+                                       .fields(
+                                               
DetailField.of("name").title("Name"),
+                                               
DetailField.of("body").title("").format(DetailField.Format.MARKDOWN))))
+                       .build();
+               var html = Html.of(ViewTable.of(v));
+               assertTrue(html.contains("data-juneau-field=\"name\""), html);
+               assertFalse(html.contains("data-juneau-field-format=\"text\""), 
html);
+               
assertTrue(html.contains("data-juneau-field-format=\"markdown\""), html);
+               assertTrue(html.contains("data-juneau-field=\"body\""), html);
+               assertTrue(html.contains("juneau-view-detail-markdown"), html);
+               assertTrue(html.contains("jc-prose"), html);
+               assertFalse(html.contains(">body</div>"), "empty markdown title 
must not fall back to the data key: " + html);
+       }
 }
diff --git 
a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_ModalResult_Test.java
 
b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_ModalResult_Test.java
index 5cbcb2bfe6..b099acd8fb 100644
--- 
a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_ModalResult_Test.java
+++ 
b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_ModalResult_Test.java
@@ -94,7 +94,8 @@ class ViewsJs_ModalResult_Test extends TestBase {
                // innerHTML uses live only in the trusted icon/caret render 
helpers, never in the action-result path.
                var body = viewsJs();
                for (var sig : new String[]{"function settleActionResponse(", 
"function renderActionOutcome(",
-                               "function showActionDialog(", "function 
submitActionDialog(", "function openActionDialog("}) {
+                               "function showActionDialog(", "function 
submitActionDialog(", "function openActionDialog(",
+                               "function appendDialogForm(", "function 
collectDialogFormFields("}) {
                        var f = fn(body, sig);
                        assertFalse(f.contains(".innerHTML"), () -> sig + " 
must never use innerHTML:\n" + f);
                        assertFalse(f.contains("insertAdjacentHTML"), () -> sig 
+ " must never use insertAdjacentHTML:\n" + f);
@@ -231,4 +232,52 @@ class ViewsJs_ModalResult_Test extends TestBase {
                assertTrue(s.contains("idempotencyKey"), s);
                assertTrue(s.contains("targetId"), s);
        }
+
+       
//------------------------------------------------------------------------------------------------------------------
+       // h) FormDef inputs: createElement + textContent / .value, never 
innerHTML; submit collects field values
+       
//------------------------------------------------------------------------------------------------------------------
+
+       @Test void h01_appendDialogFormUsesCreateElementNeverInnerHtml() throws 
Exception {
+               var body = viewsJs();
+               var append = fn(body, "function appendDialogForm(");
+               assertTrue(append.contains("createElement(\"label\")"), append);
+               assertTrue(append.contains("createElement(\"input\")"), append);
+               assertTrue(append.contains("createElement(\"textarea\")"), 
append);
+               assertTrue(append.contains(".textContent"), append);
+               assertTrue(append.contains(".value"), append);
+               assertFalse(append.contains(".innerHTML"), () -> "FormDef paint 
must never use innerHTML:\n" + append);
+               assertFalse(append.contains("insertAdjacentHTML"), append);
+               assertFalse(append.contains("form.template"), () -> "client 
must not consume form.template as markup:\n" + append);
+       }
+
+       @Test void h02_collectDialogFormFieldsUsesValueNotInnerHtml() throws 
Exception {
+               var body = viewsJs();
+               var collect = fn(body, "function collectDialogFormFields(");
+               assertTrue(collect.contains(".value"), collect);
+               assertFalse(collect.contains(".innerHTML"), () -> "collect must 
never use innerHTML:\n" + collect);
+               assertTrue(collect.contains("data-juneau-form-field"), collect);
+       }
+
+       @Test void h03_submitDialogMergesCollectedFields() throws Exception {
+               var body = viewsJs();
+               var s = fn(body, "function submitActionDialog(");
+               assertTrue(s.contains("extra.fields"), s);
+               assertTrue(s.contains("idempotencyKey"), s);
+               assertTrue(s.contains("targetId"), s);
+       }
+
+       @Test void h04_typedInputsOnly_textAndTextarea() throws Exception {
+               var body = viewsJs();
+               var typed = fn(body, "function isTypedFormInputType(");
+               assertTrue(typed.contains("\"text\""), typed);
+               assertTrue(typed.contains("\"textarea\""), typed);
+               var append = fn(body, "function appendDialogForm(");
+               assertTrue(append.contains("isTypedFormInputType(type)"), 
append);
+       }
+
+       @Test void h05_buildDialogOverlayPaintsForm() throws Exception {
+               var body = viewsJs();
+               var build = fn(body, "function buildDialogOverlay(");
+               assertTrue(build.contains("appendDialogForm(dialog, modal && 
modal.form)"), build);
+       }
 }
diff --git 
a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_RowDetail_Test.java
 
b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_RowDetail_Test.java
index f270630f7e..36da2d7a24 100644
--- 
a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_RowDetail_Test.java
+++ 
b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_RowDetail_Test.java
@@ -48,6 +48,8 @@ class ViewsJs_RowDetail_Test extends TestBase {
                        "isSafeDetailUrl: isSafeDetailUrl",
                        "substituteDetailUrl: substituteDetailUrl",
                        "scalarFieldValue: scalarFieldValue",
+                       "isSafeMarkdownHref: isSafeMarkdownHref",
+                       "fillMarkdownSlot: fillMarkdownSlot",
                        "fillDetailSlots: fillDetailSlots",
                        "findRowDetailTemplate: findRowDetailTemplate",
                        "JUNEAU_ROW_DETAIL_CONTRACT_VERSION: 
JUNEAU_ROW_DETAIL_CONTRACT_VERSION"
@@ -170,6 +172,18 @@ class ViewsJs_RowDetail_Test extends TestBase {
                assertTrue(String.valueOf(r.get("fill_xss")).contains("<img"));
                assertEquals("42", r.get("fill_num"));
                assertEquals("", r.get("fill_missing"));
+               assertEquals(true, r.get("hasFillMarkdown"));
+               assertEquals(false, r.get("md_hasScript"));
+               assertEquals(false, r.get("md_hasImg"));
+               assertEquals(false, r.get("md_jsHref"));
+               assertEquals(true, r.get("md_httpsHref"));
+               assertEquals(true, r.get("md_textHasOk"));
+               assertEquals(true, r.get("md_textHasX"));
+               assertEquals(true, r.get("md_textHasY"));
+               assertEquals(false, r.get("md_textHasAlert"));
+               assertEquals(false, r.get("href_js"));
+               assertEquals(true, r.get("href_https"));
+               assertEquals(false, r.get("href_data"));
        }
 
        @Test void b04_404500_actionRefButtonless_collapseRemains() {
diff --git a/juneau-rest/juneau-rest-server-views/src/test/js/modal-result.cjs 
b/juneau-rest/juneau-rest-server-views/src/test/js/modal-result.cjs
index fef9bb651f..6b3c251a4d 100644
--- a/juneau-rest/juneau-rest-server-views/src/test/js/modal-result.cjs
+++ b/juneau-rest/juneau-rest-server-views/src/test/js/modal-result.cjs
@@ -194,6 +194,56 @@ const PROBE = async function () {
                }
        }
 
+       // ---- FormDef inputs: typed textarea/text via createElement + .value; 
XSS prefill stays inert ----
+       {
+               const dom = makeRow('QABCDEF');
+               const evil = '<img src=x onerror=alert(1)>';
+               const modal = {
+                       title: 'Close this incident?',
+                       fields: [{ label: 'Incident', value: evil }],
+                       form: {
+                               template: '<img src=x onerror=alert(2)>',
+                               fields: [
+                                       { name: 'resolution', label: 
'Resolution comment', type: 'textarea', required: true, value: evil },
+                                       { name: 'note', label: 'Note', type: 
'text', value: 'ok' },
+                                       { name: 'skipme', label: 'Bad', type: 
'password', value: 'secret' }
+                               ]
+                       },
+                       idempotencyKey: 'key-close'
+               };
+               const action = { id: 'close', label: 'Close', endpoint: 
'/x/close', method: 'POST', present: 'dialog' };
+               const ui = init.showActionDialog(modal, action, dom.table, 
dom.tr, {});
+               await tick();
+               const backdrop = 
document.querySelector('.juneau-view-dialog-backdrop');
+               const formEl = backdrop ? 
backdrop.querySelector('.juneau-view-dialog-form') : null;
+               const textarea = formEl ? 
formEl.querySelector('textarea[data-juneau-form-field="resolution"]') : null;
+               const text = formEl ? 
formEl.querySelector('input[data-juneau-form-field="note"]') : null;
+               const password = formEl ? 
formEl.querySelector('[data-juneau-form-field="skipme"]') : null;
+               out.form = {
+                       formVisible: rendered(formEl),
+                       textareaPrefill: textarea ? textarea.value : null,
+                       notePrefill: text ? text.value : null,
+                       passwordSkipped: !password,
+                       injectedImgCount: backdrop ? 
backdrop.querySelectorAll('img').length : -1,
+                       templateNotInFormHtml: formEl ? 
formEl.innerHTML.indexOf('<img') < 0 : false
+               };
+               if (textarea) textarea.value = 'fixed in change';
+               const fetchCalls = [];
+               const realFetch = window.fetch;
+               window.fetch = function (url, opts) {
+                       fetchCalls.push({ url: url, opts: opts });
+                       return Promise.resolve(resp({ ok: true, status: 200,
+                               body: JSON.stringify({ contractVersion: V, 
outcome: 'success' }) }));
+               };
+               dom.table.setAttribute('data-juneau-csrf', 'tok-xyz');
+               ui.confirmBtn.click();
+               await tick(); await tick();
+               window.fetch = realFetch;
+               out.form.submitIssued = fetchCalls.length > 0;
+               if (fetchCalls.length > 0)
+                       out.form.submitBody = fetchCalls[0].opts.body;
+       }
+
        return out;
 };
 
diff --git a/juneau-rest/juneau-rest-server-views/src/test/js/row-detail.cjs 
b/juneau-rest/juneau-rest-server-views/src/test/js/row-detail.cjs
index 82408bbd65..28dbc27ba8 100644
--- a/juneau-rest/juneau-rest-server-views/src/test/js/row-detail.cjs
+++ b/juneau-rest/juneau-rest-server-views/src/test/js/row-detail.cjs
@@ -36,24 +36,98 @@ if (!viewsJsPath) {
        process.exit(2);
 }
 
+function el(tag) {
+       const node = {
+               nodeType: 1,
+               tagName: String(tag).toUpperCase(),
+               childNodes: [],
+               attrs: {},
+               parentNode: null,
+               get firstChild() { return this.childNodes[0] || null; },
+               getAttribute: function (k) { return Object.hasOwn(this.attrs, 
k) ? this.attrs[k] : null; },
+               setAttribute: function (k, v) { this.attrs[k] = v == null ? '' 
: String(v); },
+               appendChild: function (c) {
+                       this.childNodes.push(c);
+                       c.parentNode = this;
+                       return c;
+               },
+               removeChild: function (c) {
+                       const i = this.childNodes.indexOf(c);
+                       if (i >= 0) this.childNodes.splice(i, 1);
+                       return c;
+               },
+               replaceChildren: function () { this.childNodes.length = 0; },
+               set textContent(v) { this.childNodes.length = 0; this._text = v 
== null ? '' : String(v); },
+               get textContent() {
+                       if (this.childNodes.length === 0) return this._text || 
'';
+                       return this.childNodes.map(function (c) { return 
c.textContent; }).join('');
+               }
+       };
+       node._text = '';
+       return node;
+}
+
+function textNode(value) {
+       return {
+               nodeType: 3,
+               nodeValue: value == null ? '' : String(value),
+               childNodes: [],
+               get textContent() { return this.nodeValue; }
+       };
+}
+
+const VOID_TAGS = { br: 1, hr: 1, img: 1, input: 1, meta: 1, link: 1, base: 1 
};
+
+function parseAttrs(raw, node) {
+       if (!raw) return;
+       const re = /([:@\w-]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g;
+       let m;
+       while ((m = re.exec(raw)))
+               node.setAttribute(m[1], m[2] != null ? m[2] : (m[3] != null ? 
m[3] : (m[4] != null ? m[4] : '')));
+}
+
+function parseTestHtml(html) {
+       const root = el('div');
+       const stack = [root];
+       const re = /<\/?([a-zA-Z][a-zA-Z0-9]*)\b([^>]*)\/?>|([^<]+)/g;
+       let m;
+       while ((m = re.exec(html))) {
+               if (m[3] != null) {
+                       stack[stack.length - 1].appendChild(textNode(m[3]));
+                       continue;
+               }
+               const name = m[1];
+               const closing = html.charAt(m.index + 1) === '/';
+               if (closing) {
+                       if (stack.length > 1) stack.pop();
+                       continue;
+               }
+               const node = el(name);
+               parseAttrs(m[2], node);
+               stack[stack.length - 1].appendChild(node);
+               const selfClosing = VOID_TAGS[name.toLowerCase()] || 
/\/\s*$/.test(m[2] || '');
+               if (!selfClosing) stack.push(node);
+       }
+       return root;
+}
+
+function DOMParser() {}
+DOMParser.prototype.parseFromString = function (str) {
+       return { body: { firstChild: parseTestHtml(str) } };
+};
+
 const document = {
        readyState: 'loading',
        addEventListener: function () {},
        querySelectorAll: function () { return []; },
        querySelector: function () { return null; },
        getElementById: function () { return null; },
-       createElement: function () {
-               return {
-                       setAttribute: function () {},
-                       appendChild: function () {},
-                       querySelector: function () { return null; },
-                       querySelectorAll: function () { return []; }
-               };
-       },
+       createElement: function (tag) { return el(tag); },
+       createTextNode: function (v) { return textNode(v); },
        body: { appendChild: function () {}, querySelectorAll: function () { 
return []; } }
 };
-const window = { document: document, console: console, jQuery: undefined };
-const sandbox = { window: window, document: document, console: console };
+const window = { document: document, console: console, jQuery: undefined, 
DOMParser: DOMParser };
+const sandbox = { window: window, document: document, console: console, 
DOMParser: DOMParser };
 vm.runInNewContext(fs.readFileSync(path.resolve(viewsJsPath), 'utf8'), 
sandbox, { filename: 'juneau-views.js' });
 
 const NS = window.JuneauViews;
@@ -111,6 +185,51 @@ out.fill_num = notes.textContent;
 out.fill_missing = extra.textContent;
 out.fill_xssNotInterpreted = title.textContent === xss;
 
+function markdownSlot() {
+       const s = el('div');
+       s.attrs['data-juneau-field'] = 'body';
+       s.attrs['data-juneau-field-format'] = 'markdown';
+       return s;
+}
+function collectTags(node, acc) {
+       if (!node || node.nodeType !== 1) return acc;
+       acc.push(node.tagName);
+       for (let i = 0; i < node.childNodes.length; i++) 
collectTags(node.childNodes[i], acc);
+       return acc;
+}
+function findTag(node, tag, list) {
+       if (!node || node.nodeType !== 1) return list;
+       if (node.tagName === tag) list.push(node);
+       for (let i = 0; i < node.childNodes.length; i++) 
findTag(node.childNodes[i], tag, list);
+       return list;
+}
+const mdSlot = markdownSlot();
+const mdWrap = {
+       querySelectorAll: function (sel) {
+               if (sel === '[data-juneau-field]') return [mdSlot];
+               return [];
+       }
+};
+I.fillDetailSlots(mdWrap, { body:
+       '<p>ok</p><script>alert(1)</script><p><a 
href="javascript:alert(1)">x</a></p>'
+       + '<p><a href="https://ok";>y</a></p><p><img src=x 
onerror="alert(1)"></p>'
+});
+const mdTags = collectTags(mdSlot, []);
+out.md_tags = mdTags.join(',');
+out.md_hasScript = mdTags.indexOf('SCRIPT') >= 0;
+out.md_hasImg = mdTags.indexOf('IMG') >= 0;
+const anchors = findTag(mdSlot, 'A', []);
+out.md_jsHref = anchors.some(function (a) { return 
String(a.getAttribute('href') || '').indexOf('javascript:') >= 0; });
+out.md_httpsHref = anchors.some(function (a) { return a.getAttribute('href') 
=== 'https://ok'; });
+out.md_textHasOk = mdSlot.textContent.indexOf('ok') >= 0;
+out.md_textHasX = mdSlot.textContent.indexOf('x') >= 0;
+out.md_textHasY = mdSlot.textContent.indexOf('y') >= 0;
+out.md_textHasAlert = mdSlot.textContent.indexOf('alert(1)') >= 0;
+out.href_js = I.isSafeMarkdownHref('javascript:alert(1)');
+out.href_https = I.isSafeMarkdownHref('https://x');
+out.href_data = I.isSafeMarkdownHref('data:text/html,x');
+out.hasFillMarkdown = typeof I.fillMarkdownSlot === 'function';
+
 function btn(id) {
        return { attrs: { 'data-juneau-action': id }, disabled: false, hidden: 
false,
                getAttribute: function (k) { return this.attrs[k]; } };

Reply via email to