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

lukaszlenart pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/struts.git


The following commit(s) were added to refs/heads/main by this push:
     new 789dbf3cd WW-5640 Add WebJars support to Struts core (#1765)
789dbf3cd is described below

commit 789dbf3cd253d528af230272805b3ce392005fb1
Author: Lukasz Lenart <[email protected]>
AuthorDate: Mon Jul 6 15:03:05 2026 +0200

    WW-5640 Add WebJars support to Struts core (#1765)
    
    * WW-5640 docs: design for WebJars support in Struts core
    
    Adds first-class WebJars support so client-side libraries can be
    referenced by a version-less logical path and served through the
    existing static-content pipeline. Grounded against 7.2.x source.
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * WW-5640 docs: implementation plan for WebJars support
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * WW-5640 build: add webjars-locator-lite dependency
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * WW-5640 feat: add webjars config constants and defaults
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * WW-5640 docs: correct plan test framework to JUnit 4
    
    core uses JUnit 4 + AssertJ + Mockito, not JUnit 5 Jupiter (no
    Jupiter engine on the classpath). Test tasks translate accordingly.
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * WW-5640 feat: add WebJarUrlProvider resolution seam
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * WW-5640 feat: register WebJarUrlProvider bean
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * WW-5640 feat: extend static content-type map for webjar assets
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * WW-5640 feat: serve webjar assets via static content loader
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * WW-5640 feat: add <s:webjar> tag and <@s.webjar> macro
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * WW-5640 docs: add generated tag reference for <s:webjar>
    
    Annotation-processor-generated tag reference (attributes + description),
    tracked like every other tag's docs under core/src/site/resources/tags/.
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * WW-5640 fix: address final review (log level, resolveUrl traversal test, 
javadoc)
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * WW-5640 refactor: address SonarCloud code smells
    
    - getContentType: replace long if/else chain with a static extension->
      MIME map (S3776 cognitive complexity)
    - DefaultWebJarUrlProvider.split: return Optional<String[]> instead of a
      null sentinel (S1168; Optional fits the reject semantics, empty-array
      would not)
    - serving tests: rename local 'loader' -> 'webJarLoader' to stop hiding
      the ContentTypeProbe field (S1117)
    - WebJarTest: use assertThat(writer).hasToString(...) (S5838)
    
    S110 (WebJarTag inheritance depth) is inherent to the Struts tag base
    class hierarchy shared by every tag; left as-is.
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    ---------
    
    Co-authored-by: Claude Opus 4.8 <[email protected]>
---
 core/pom.xml                                       |   11 +
 .../java/org/apache/struts2/StrutsConstants.java   |   15 +
 .../java/org/apache/struts2/components/WebJar.java |   95 ++
 .../config/StrutsBeanSelectionProvider.java        |    2 +
 .../dispatcher/DefaultStaticContentLoader.java     |   93 +-
 .../views/freemarker/tags/StrutsModels.java        |    8 +
 .../struts2/views/freemarker/tags/WebJarModel.java |   37 +
 .../org/apache/struts2/views/jsp/WebJarTag.java    |   48 +
 .../struts2/webjars/DefaultWebJarUrlProvider.java  |  151 +++
 .../apache/struts2/webjars/WebJarUrlProvider.java  |   51 +
 .../org/apache/struts2/default.properties          |    6 +
 core/src/main/resources/struts-beans.xml           |    2 +
 .../src/site/resources/tags/webjar-attributes.html |   40 +
 .../site/resources/tags/webjar-description.html    |    1 +
 .../org/apache/struts2/components/WebJarTest.java  |   81 ++
 .../DefaultStaticContentLoaderWebJarTest.java      |  119 ++
 .../dispatcher/WebJarTestServletOutputStream.java  |   50 +
 .../webjars/DefaultWebJarUrlProviderTest.java      |  110 ++
 .../plans/2026-07-01-WW-5640-webjars-support.md    | 1396 ++++++++++++++++++++
 .../specs/2026-07-01-webjars-support-design.md     |  146 ++
 parent/pom.xml                                     |   12 +
 pom.xml                                            |    2 +
 22 files changed, 2458 insertions(+), 18 deletions(-)

diff --git a/core/pom.xml b/core/pom.xml
index 784a9053d..b5303545f 100644
--- a/core/pom.xml
+++ b/core/pom.xml
@@ -153,6 +153,17 @@
             <artifactId>caffeine</artifactId>
         </dependency>
 
+        <dependency>
+            <groupId>org.webjars</groupId>
+            <artifactId>webjars-locator-lite</artifactId>
+        </dependency>
+
+        <dependency>
+            <groupId>org.webjars</groupId>
+            <artifactId>jquery</artifactId>
+            <scope>test</scope>
+        </dependency>
+
         <dependency>
             <groupId>jakarta.servlet</groupId>
             <artifactId>jakarta.servlet-api</artifactId>
diff --git a/core/src/main/java/org/apache/struts2/StrutsConstants.java 
b/core/src/main/java/org/apache/struts2/StrutsConstants.java
index 0e9ca4fc3..e76fac4e5 100644
--- a/core/src/main/java/org/apache/struts2/StrutsConstants.java
+++ b/core/src/main/java/org/apache/struts2/StrutsConstants.java
@@ -195,6 +195,16 @@ public final class StrutsConstants {
      */
     public static final String STRUTS_UI_STATIC_CONTENT_PATH = 
"struts.ui.staticContentPath";
 
+    /**
+     * Whether WebJars support is enabled (serving and URL building)
+     */
+    public static final String STRUTS_WEBJARS_ENABLED = 
"struts.webjars.enabled";
+
+    /**
+     * Optional comma-separated allowlist of WebJar names permitted to be 
served (empty = all)
+     */
+    public static final String STRUTS_WEBJARS_ALLOWLIST = 
"struts.webjars.allowlist";
+
     /**
      * A global flag to enable/disable html body escaping in tags, can be 
overwritten per tag
      */
@@ -434,6 +444,11 @@ public final class StrutsConstants {
      */
     public static final String STRUTS_STATIC_CONTENT_LOADER = 
"struts.staticContentLoader";
 
+    /**
+     * The {@link org.apache.struts2.webjars.WebJarUrlProvider} implementation 
class
+     */
+    public static final String STRUTS_WEBJARS_URL_PROVIDER = 
"struts.webjars.urlProvider";
+
     /**
      * The {@link org.apache.struts2.UnknownHandlerManager} implementation 
class
      */
diff --git a/core/src/main/java/org/apache/struts2/components/WebJar.java 
b/core/src/main/java/org/apache/struts2/components/WebJar.java
new file mode 100644
index 000000000..c18538a42
--- /dev/null
+++ b/core/src/main/java/org/apache/struts2/components/WebJar.java
@@ -0,0 +1,95 @@
+/*
+ * 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.struts2.components;
+
+import jakarta.servlet.http.HttpServletRequest;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.apache.struts2.inject.Inject;
+import org.apache.struts2.util.ValueStack;
+import org.apache.struts2.views.annotations.StrutsTag;
+import org.apache.struts2.views.annotations.StrutsTagAttribute;
+import org.apache.struts2.webjars.WebJarUrlProvider;
+
+import java.io.IOException;
+import java.io.Writer;
+import java.util.Optional;
+
+/**
+ * <p>Resolves a version-less WebJar resource path to a servable URL and 
writes it to the output
+ * (or stores it in a variable when {@code var} is set). Compose it with 
{@code <s:script>}/{@code <s:link>}
+ * or a raw {@code <link>}/{@code <script>} element.</p>
+ *
+ * <b>Examples</b>
+ * <pre>
+ *   &lt;link rel="stylesheet" href="&lt;s:webjar 
path="bootstrap/css/bootstrap.min.css" /&gt;" /&gt;
+ *   &lt;@s.webjar path="jquery/jquery.min.js"/&gt;
+ * </pre>
+ */
+@StrutsTag(
+    name = "webjar",
+    tldTagClass = "org.apache.struts2.views.jsp.WebJarTag",
+    description = "Resolve a version-less WebJar resource path to a servable 
URL")
+public class WebJar extends ContextBean {
+
+    private static final Logger LOG = LogManager.getLogger(WebJar.class);
+
+    protected String path;
+
+    private final HttpServletRequest request;
+    private WebJarUrlProvider webJarUrlProvider;
+
+    public WebJar(ValueStack stack, HttpServletRequest request) {
+        super(stack);
+        this.request = request;
+    }
+
+    @Inject
+    public void setWebJarUrlProvider(WebJarUrlProvider webJarUrlProvider) {
+        this.webJarUrlProvider = webJarUrlProvider;
+    }
+
+    @Override
+    public boolean end(Writer writer, String body) {
+        String logicalPath = findString(path);
+        Optional<String> url = (logicalPath == null)
+            ? Optional.empty()
+            : webJarUrlProvider.resolveUrl(logicalPath, request);
+
+        if (url.isPresent()) {
+            if (StringUtils.isNotBlank(getVar())) {
+                putInContext(url.get());
+            } else {
+                try {
+                    writer.write(url.get());
+                } catch (IOException e) {
+                    LOG.error("Could not write WebJar URL for path '{}'", 
path, e);
+                }
+            }
+        }
+        return super.end(writer, body);
+    }
+
+    @StrutsTagAttribute(required = true,
+        description = "The version-less WebJar resource path, e.g. 
bootstrap/css/bootstrap.min.css")
+    public void setPath(String path) {
+        this.path = path;
+    }
+}
diff --git 
a/core/src/main/java/org/apache/struts2/config/StrutsBeanSelectionProvider.java 
b/core/src/main/java/org/apache/struts2/config/StrutsBeanSelectionProvider.java
index e3f632eda..f37cb4752 100644
--- 
a/core/src/main/java/org/apache/struts2/config/StrutsBeanSelectionProvider.java
+++ 
b/core/src/main/java/org/apache/struts2/config/StrutsBeanSelectionProvider.java
@@ -84,6 +84,7 @@ import org.apache.struts2.util.reflection.ReflectionProvider;
 import org.apache.struts2.validator.ActionValidatorManager;
 import org.apache.struts2.views.freemarker.FreemarkerManager;
 import org.apache.struts2.views.util.UrlHelper;
+import org.apache.struts2.webjars.WebJarUrlProvider;
 
 /**
  * Selects the implementations of key framework extension points, using the 
loaded
@@ -430,6 +431,7 @@ public class StrutsBeanSelectionProvider extends 
AbstractBeanSelectionProvider {
         alias(PatternMatcher.class, StrutsConstants.STRUTS_PATTERNMATCHER, 
builder, props);
         alias(ContentTypeMatcher.class, 
StrutsConstants.STRUTS_CONTENT_TYPE_MATCHER, builder, props);
         alias(StaticContentLoader.class, 
StrutsConstants.STRUTS_STATIC_CONTENT_LOADER, builder, props);
+        alias(WebJarUrlProvider.class, 
StrutsConstants.STRUTS_WEBJARS_URL_PROVIDER, builder, props);
         alias(UnknownHandlerManager.class, 
StrutsConstants.STRUTS_UNKNOWN_HANDLER_MANAGER, builder, props);
         alias(UrlHelper.class, StrutsConstants.STRUTS_URL_HELPER, builder, 
props);
 
diff --git 
a/core/src/main/java/org/apache/struts2/dispatcher/DefaultStaticContentLoader.java
 
b/core/src/main/java/org/apache/struts2/dispatcher/DefaultStaticContentLoader.java
index 18f4c54dd..3b7a64fd2 100644
--- 
a/core/src/main/java/org/apache/struts2/dispatcher/DefaultStaticContentLoader.java
+++ 
b/core/src/main/java/org/apache/struts2/dispatcher/DefaultStaticContentLoader.java
@@ -27,6 +27,7 @@ import org.apache.commons.lang3.StringUtils;
 import org.apache.logging.log4j.LogManager;
 import org.apache.logging.log4j.Logger;
 import org.apache.struts2.StrutsConstants;
+import org.apache.struts2.webjars.WebJarUrlProvider;
 
 import java.io.IOException;
 import java.io.InputStream;
@@ -39,6 +40,8 @@ import java.util.Calendar;
 import java.util.Collections;
 import java.util.LinkedList;
 import java.util.List;
+import java.util.Map;
+import java.util.Optional;
 import java.util.StringTokenizer;
 
 /**
@@ -70,6 +73,8 @@ import java.util.StringTokenizer;
  */
 public class DefaultStaticContentLoader implements StaticContentLoader {
 
+    protected static final String WEBJARS_REQUEST_PREFIX = "/webjars/";
+
     /**
      * Provide a logging instance.
      */
@@ -107,6 +112,13 @@ public class DefaultStaticContentLoader implements 
StaticContentLoader {
 
     protected boolean devMode;
 
+    protected WebJarUrlProvider webJarUrlProvider;
+
+    @Inject
+    public void setWebJarUrlProvider(WebJarUrlProvider webJarUrlProvider) {
+        this.webJarUrlProvider = webJarUrlProvider;
+    }
+
     /**
      * Modify state of StrutsConstants.STRUTS_SERVE_STATIC_CONTENT setting.
      *
@@ -209,6 +221,14 @@ public class DefaultStaticContentLoader implements 
StaticContentLoader {
     public void findStaticResource(String path, HttpServletRequest request, 
HttpServletResponse response)
         throws IOException {
         String name = cleanupPath(path);
+
+        if (name.startsWith(WEBJARS_REQUEST_PREFIX)) {
+            if (!findWebJarResource(name, path, request, response)) {
+                sendNotFound(response);
+            }
+            return;
+        }
+
         for (String pathPrefix : pathPrefixes) {
             URL resourceUrl = findResource(buildPath(name, pathPrefix));
             if (resourceUrl != null) {
@@ -231,6 +251,32 @@ public class DefaultStaticContentLoader implements 
StaticContentLoader {
             }
         }
 
+        sendNotFound(response);
+    }
+
+    /**
+     * Resolve and serve a WebJar asset requested under {@code 
<staticContentPath>/webjars/**}.
+     *
+     * @param name    the request path with the static-content prefix 
stripped, e.g. {@code /webjars/jquery/jquery.min.js}
+     * @param path    the original request path (used for content-type 
detection)
+     * @return true if the asset was resolved and streamed; false otherwise 
(caller sends 404)
+     */
+    protected boolean findWebJarResource(String name, String path, 
HttpServletRequest request, HttpServletResponse response)
+        throws IOException {
+        String logicalPath = name.substring(WEBJARS_REQUEST_PREFIX.length());
+        Optional<String> resource = 
webJarUrlProvider.resolveResourcePath(logicalPath);
+        if (resource.isEmpty()) {
+            return false;
+        }
+        URL resourceUrl = findResource(resource.get());
+        if (resourceUrl == null) {
+            return false;
+        }
+        process(resourceUrl.openStream(), path, request, response);
+        return true;
+    }
+
+    protected void sendNotFound(HttpServletResponse response) {
         try {
             response.sendError(HttpServletResponse.SC_NOT_FOUND);
         } catch (IOException e1) {
@@ -321,32 +367,43 @@ public class DefaultStaticContentLoader implements 
StaticContentLoader {
     }
 
 
+    /**
+     * Maps a lower-case file extension to its content type. Not using the 
code provided by
+     * activation.jar to avoid adding yet another dependency; this covers the 
files we serve up
+     * (Struts' own static assets plus WebJar assets).
+     */
+    private static final Map<String, String> CONTENT_TYPES = Map.ofEntries(
+        Map.entry("js", "text/javascript"),
+        Map.entry("mjs", "text/javascript"),
+        Map.entry("css", "text/css"),
+        Map.entry("html", "text/html"),
+        Map.entry("txt", "text/plain"),
+        Map.entry("gif", "image/gif"),
+        Map.entry("jpg", "image/jpeg"),
+        Map.entry("jpeg", "image/jpeg"),
+        Map.entry("png", "image/png"),
+        Map.entry("svg", "image/svg+xml"),
+        Map.entry("ico", "image/x-icon"),
+        Map.entry("woff2", "font/woff2"),
+        Map.entry("woff", "font/woff"),
+        Map.entry("ttf", "font/ttf"),
+        Map.entry("otf", "font/otf"),
+        Map.entry("eot", "application/vnd.ms-fontobject"),
+        Map.entry("json", "application/json"),
+        Map.entry("map", "application/json"));
+
     /**
      * Determine the content type for the resource name.
      *
      * @param name The resource name
-     * @return The mime type
+     * @return The mime type, or {@code null} if the extension is unknown
      */
     protected String getContentType(String name) {
-        // NOT using the code provided activation.jar to avoid adding yet 
another dependency
-        // this is generally OK, since these are the main files we server up
-        if (name.endsWith(".js")) {
-            return "text/javascript";
-        } else if (name.endsWith(".css")) {
-            return "text/css";
-        } else if (name.endsWith(".html")) {
-            return "text/html";
-        } else if (name.endsWith(".txt")) {
-            return "text/plain";
-        } else if (name.endsWith(".gif")) {
-            return "image/gif";
-        } else if (name.endsWith(".jpg") || name.endsWith(".jpeg")) {
-            return "image/jpeg";
-        } else if (name.endsWith(".png")) {
-            return "image/png";
-        } else {
+        int dot = name.lastIndexOf('.');
+        if (dot < 0) {
             return null;
         }
+        return CONTENT_TYPES.get(name.substring(dot + 1));
     }
 
     /**
diff --git 
a/core/src/main/java/org/apache/struts2/views/freemarker/tags/StrutsModels.java 
b/core/src/main/java/org/apache/struts2/views/freemarker/tags/StrutsModels.java
index dea1ac8ee..cdfec8138 100644
--- 
a/core/src/main/java/org/apache/struts2/views/freemarker/tags/StrutsModels.java
+++ 
b/core/src/main/java/org/apache/struts2/views/freemarker/tags/StrutsModels.java
@@ -53,6 +53,7 @@ public class StrutsModels {
     protected RadioModel radio;
     protected SelectModel select;
     protected SetModel set;
+    protected WebJarModel webjar;
     protected SubmitModel submit;
     protected ResetModel reset;
     protected TextAreaModel textarea;
@@ -339,6 +340,13 @@ public class StrutsModels {
         return set;
     }
 
+    public WebJarModel getWebjar() {
+        if (webjar == null) {
+            webjar = new WebJarModel(stack, req, res);
+        }
+        return webjar;
+    }
+
     public PropertyModel getProperty() {
         if (property == null) {
             property = new PropertyModel(stack, req, res);
diff --git 
a/core/src/main/java/org/apache/struts2/views/freemarker/tags/WebJarModel.java 
b/core/src/main/java/org/apache/struts2/views/freemarker/tags/WebJarModel.java
new file mode 100644
index 000000000..70e8bb7d1
--- /dev/null
+++ 
b/core/src/main/java/org/apache/struts2/views/freemarker/tags/WebJarModel.java
@@ -0,0 +1,37 @@
+/*
+ * 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.struts2.views.freemarker.tags;
+
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.apache.struts2.components.Component;
+import org.apache.struts2.components.WebJar;
+import org.apache.struts2.util.ValueStack;
+
+public class WebJarModel extends TagModel {
+
+    public WebJarModel(ValueStack stack, HttpServletRequest req, 
HttpServletResponse res) {
+        super(stack, req, res);
+    }
+
+    @Override
+    protected Component getBean() {
+        return new WebJar(stack, req);
+    }
+}
diff --git a/core/src/main/java/org/apache/struts2/views/jsp/WebJarTag.java 
b/core/src/main/java/org/apache/struts2/views/jsp/WebJarTag.java
new file mode 100644
index 000000000..2747b4f87
--- /dev/null
+++ b/core/src/main/java/org/apache/struts2/views/jsp/WebJarTag.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.struts2.views.jsp;
+
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.apache.struts2.components.Component;
+import org.apache.struts2.components.WebJar;
+import org.apache.struts2.util.ValueStack;
+
+public class WebJarTag extends ContextBeanTag {
+
+    private static final long serialVersionUID = 1L;
+
+    protected String path;
+
+    @Override
+    public Component getBean(ValueStack stack, HttpServletRequest req, 
HttpServletResponse res) {
+        return new WebJar(stack, req);
+    }
+
+    @Override
+    protected void populateParams() {
+        super.populateParams();
+        WebJar webJar = (WebJar) component;
+        webJar.setPath(path);
+    }
+
+    public void setPath(String path) {
+        this.path = path;
+    }
+}
diff --git 
a/core/src/main/java/org/apache/struts2/webjars/DefaultWebJarUrlProvider.java 
b/core/src/main/java/org/apache/struts2/webjars/DefaultWebJarUrlProvider.java
new file mode 100644
index 000000000..0b99f85b8
--- /dev/null
+++ 
b/core/src/main/java/org/apache/struts2/webjars/DefaultWebJarUrlProvider.java
@@ -0,0 +1,151 @@
+/*
+ * 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.struts2.webjars;
+
+import jakarta.servlet.http.HttpServletRequest;
+import org.apache.commons.lang3.BooleanUtils;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.apache.struts2.StrutsConstants;
+import org.apache.struts2.dispatcher.StaticContentLoader;
+import org.apache.struts2.inject.Inject;
+import org.webjars.WebJarVersionLocator;
+
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Optional;
+import java.util.Set;
+
+/**
+ * Default {@link WebJarUrlProvider} backed by a singleton {@link 
WebJarVersionLocator}
+ * (from {@code webjars-locator-lite}). Thread-safe.
+ */
+public class DefaultWebJarUrlProvider implements WebJarUrlProvider {
+
+    private static final Logger LOG = 
LogManager.getLogger(DefaultWebJarUrlProvider.class);
+
+    private static final String WEBJARS_URL_SEGMENT = "/webjars/";
+
+    private final WebJarVersionLocator locator = new WebJarVersionLocator();
+
+    private boolean enabled = true;
+    private Set<String> allowlist = Collections.emptySet();
+    private String uiStaticContentPath = 
StaticContentLoader.DEFAULT_STATIC_CONTENT_PATH;
+
+    @Inject(value = StrutsConstants.STRUTS_WEBJARS_ENABLED, required = false)
+    public void setEnabled(String enabled) {
+        this.enabled = BooleanUtils.toBoolean(enabled);
+    }
+
+    @Inject(value = StrutsConstants.STRUTS_WEBJARS_ALLOWLIST, required = false)
+    public void setAllowlist(String allowlist) {
+        Set<String> names = new HashSet<>();
+        if (StringUtils.isNotBlank(allowlist)) {
+            for (String name : allowlist.split(",")) {
+                String trimmed = name.trim();
+                if (!trimmed.isEmpty()) {
+                    names.add(trimmed);
+                }
+            }
+        }
+        this.allowlist = Collections.unmodifiableSet(names);
+    }
+
+    @Inject(StrutsConstants.STRUTS_UI_STATIC_CONTENT_PATH)
+    public void setStaticContentPath(String uiStaticContentPath) {
+        this.uiStaticContentPath = 
StaticContentLoader.Validator.validateStaticContentPath(uiStaticContentPath);
+    }
+
+    @Override
+    public boolean isEnabled() {
+        return enabled;
+    }
+
+    @Override
+    public Optional<String> resolveResourcePath(String logicalPath) {
+        Optional<String[]> parts = split(logicalPath);
+        if (parts.isEmpty()) {
+            return Optional.empty();
+        }
+        String[] p = parts.get();
+        String full = locator.fullPath(p[0], p[1]);
+        if (full == null || 
!full.startsWith(WebJarVersionLocator.WEBJARS_PATH_PREFIX + "/")) {
+            return Optional.empty();
+        }
+        return Optional.of(full);
+    }
+
+    @Override
+    public Optional<String> resolveUrl(String logicalPath, HttpServletRequest 
request) {
+        Optional<String[]> parts = split(logicalPath);
+        if (parts.isEmpty()) {
+            return Optional.empty();
+        }
+        String[] p = parts.get();
+        String versioned = locator.path(p[0], p[1]);
+        if (versioned == null) {
+            return Optional.empty();
+        }
+        StringBuilder url = new StringBuilder();
+        String contextPath = request.getContextPath();
+        if (StringUtils.isNotEmpty(contextPath) && !"/".equals(contextPath)) {
+            url.append(contextPath);
+        }
+        
url.append(uiStaticContentPath).append(WEBJARS_URL_SEGMENT).append(versioned);
+        return Optional.of(url.toString());
+    }
+
+    /**
+     * Normalize, validate and split a logical path into {webJarName, 
filePath}.
+     *
+     * @return the two-element {webJarName, filePath} pair, or {@link 
Optional#empty()} if disabled,
+     *         blank, single-segment, traversal-tainted, or allowlist-blocked
+     */
+    private Optional<String[]> split(String logicalPath) {
+        if (!enabled || StringUtils.isBlank(logicalPath)) {
+            return Optional.empty();
+        }
+        String normalized = StringUtils.stripStart(logicalPath, "/");
+        if (normalized.contains("\\")) {
+            return Optional.empty();
+        }
+        for (String segment : normalized.split("/")) {
+            if (segment.equals("..") || segment.equals(".")) {
+                LOG.debug("Rejecting WebJar path with traversal segment: {}", 
logicalPath);
+                return Optional.empty();
+            }
+        }
+        int slash = normalized.indexOf('/');
+        if (slash < 1 || slash == normalized.length() - 1) {
+            return Optional.empty();
+        }
+        String webJarName = normalized.substring(0, slash);
+        String filePath = normalized.substring(slash + 1);
+        if (!isAllowed(webJarName)) {
+            LOG.debug("WebJar '{}' is not on the allowlist", webJarName);
+            return Optional.empty();
+        }
+        return Optional.of(new String[]{webJarName, filePath});
+    }
+
+    private boolean isAllowed(String webJarName) {
+        return allowlist.isEmpty() || allowlist.contains(webJarName);
+    }
+}
diff --git 
a/core/src/main/java/org/apache/struts2/webjars/WebJarUrlProvider.java 
b/core/src/main/java/org/apache/struts2/webjars/WebJarUrlProvider.java
new file mode 100644
index 000000000..8c4b64777
--- /dev/null
+++ b/core/src/main/java/org/apache/struts2/webjars/WebJarUrlProvider.java
@@ -0,0 +1,51 @@
+/*
+ * 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.struts2.webjars;
+
+import jakarta.servlet.http.HttpServletRequest;
+
+import java.util.Optional;
+
+/**
+ * Resolves version-less WebJar logical paths (e.g. {@code 
bootstrap/css/bootstrap.min.css}) to either a
+ * concrete classpath resource under {@code META-INF/resources/webjars/} (for 
serving) or a servable URL
+ * (for tags/macros). Resolution is constrained to the WebJars root, honours 
an optional allowlist and the
+ * {@code struts.webjars.enabled} switch, and fails closed (empty result) when 
unresolved or blocked.
+ */
+public interface WebJarUrlProvider {
+
+    /**
+     * @param logicalPath version-less path such as {@code 
bootstrap/css/bootstrap.min.css}
+     * @return the concrete classpath resource path (e.g.
+     *         {@code 
META-INF/resources/webjars/bootstrap/5.3.8/css/bootstrap.min.css}), or empty
+     */
+    Optional<String> resolveResourcePath(String logicalPath);
+
+    /**
+     * @param logicalPath version-less path such as {@code 
bootstrap/css/bootstrap.min.css}
+     * @param request     the current request (used for the servlet context 
path)
+     * @return a servable URL, or empty
+     */
+    Optional<String> resolveUrl(String logicalPath, HttpServletRequest 
request);
+
+    /**
+     * @return whether WebJars support is enabled
+     */
+    boolean isEnabled();
+}
diff --git a/core/src/main/resources/org/apache/struts2/default.properties 
b/core/src/main/resources/org/apache/struts2/default.properties
index e034f2835..1c501cb33 100644
--- a/core/src/main/resources/org/apache/struts2/default.properties
+++ b/core/src/main/resources/org/apache/struts2/default.properties
@@ -106,6 +106,12 @@ struts.ui.staticContentPath=/static
 ###            headers)
 struts.serve.static.browserCache=true
 
+### WebJars support
+### Master switch for resolving/serving WebJar assets under 
<staticContentPath>/webjars/**
+struts.webjars.enabled=true
+### Optional comma-separated allowlist of WebJar names (empty = all WebJars on 
the classpath)
+struts.webjars.allowlist=
+
 ### Set this to false if you wish to disable implicit dynamic method invocation
 ### via the URL request. This includes URLs like foo!bar.action, as well as 
params
 ### like method:bar (but not action:foo).
diff --git a/core/src/main/resources/struts-beans.xml 
b/core/src/main/resources/struts-beans.xml
index 21c4ec7e6..90912b84e 100644
--- a/core/src/main/resources/struts-beans.xml
+++ b/core/src/main/resources/struts-beans.xml
@@ -207,6 +207,8 @@
 
     <bean type="org.apache.struts2.dispatcher.StaticContentLoader"
           class="org.apache.struts2.dispatcher.DefaultStaticContentLoader" 
name="struts"/>
+    <bean type="org.apache.struts2.webjars.WebJarUrlProvider"
+          class="org.apache.struts2.webjars.DefaultWebJarUrlProvider" 
name="struts"/>
     <bean type="org.apache.struts2.UnknownHandlerManager"
           class="org.apache.struts2.DefaultUnknownHandlerManager" 
name="struts"/>
 
diff --git a/core/src/site/resources/tags/webjar-attributes.html 
b/core/src/site/resources/tags/webjar-attributes.html
new file mode 100644
index 000000000..60b8c0869
--- /dev/null
+++ b/core/src/site/resources/tags/webjar-attributes.html
@@ -0,0 +1,40 @@
+<table class="tag-reference">
+    <tr>
+        <td colspan="6"><h4>Dynamic Attributes Allowed:</h4> false</td>
+    </tr>
+    <tr>
+        <td colspan="6"><hr/></td>
+    </tr>
+    <tr>
+        <th class="tag-header"><h4>Name</h4></th>
+        <th class="tag-header"><h4>Required</h4></th>
+        <th class="tag-header"><h4>Default</h4></th>
+        <th class="tag-header"><h4>Evaluated</h4></th>
+        <th class="tag-header"><h4>Type</h4></th>
+        <th class="tag-header"><h4>Description</h4></th>
+    </tr>
+    <tr>
+        <td class="tag-attribute">path</td>
+        <td class="tag-attribute"><strong>true</strong></td>
+        <td class="tag-attribute"></td>
+        <td class="tag-attribute">false</td>
+        <td class="tag-attribute">String</td>
+        <td class="tag-attribute">The version-less WebJar resource path, e.g. 
bootstrap/css/bootstrap.min.css</td>
+    </tr>
+    <tr>
+        <td class="tag-attribute">performClearTagStateForTagPoolingServers</td>
+        <td class="tag-attribute">false</td>
+        <td class="tag-attribute">false</td>
+        <td class="tag-attribute">false</td>
+        <td class="tag-attribute">Boolean</td>
+        <td class="tag-attribute">Whether to clear all tag state during 
doEndTag() processing (if applicable)</td>
+    </tr>
+    <tr>
+        <td class="tag-attribute">var</td>
+        <td class="tag-attribute">false</td>
+        <td class="tag-attribute"></td>
+        <td class="tag-attribute">false</td>
+        <td class="tag-attribute">String</td>
+        <td class="tag-attribute">Name used to reference the value pushed into 
the Value Stack (scope: action).</td>
+    </tr>
+</table>
diff --git a/core/src/site/resources/tags/webjar-description.html 
b/core/src/site/resources/tags/webjar-description.html
new file mode 100644
index 000000000..fd8e7d333
--- /dev/null
+++ b/core/src/site/resources/tags/webjar-description.html
@@ -0,0 +1 @@
+Resolve a version-less WebJar resource path to a servable URL
diff --git a/core/src/test/java/org/apache/struts2/components/WebJarTest.java 
b/core/src/test/java/org/apache/struts2/components/WebJarTest.java
new file mode 100644
index 000000000..2a3d0b8b6
--- /dev/null
+++ b/core/src/test/java/org/apache/struts2/components/WebJarTest.java
@@ -0,0 +1,81 @@
+/*
+ * 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.struts2.components;
+
+import jakarta.servlet.http.HttpServletRequest;
+import org.apache.struts2.util.ValueStack;
+import org.apache.struts2.webjars.WebJarUrlProvider;
+import org.junit.Test;
+
+import java.io.StringWriter;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+public class WebJarTest {
+
+    private WebJar newComponent(ValueStack stack, HttpServletRequest request, 
WebJarUrlProvider provider) {
+        Map<String, Object> context = new HashMap<>();
+        when(stack.getContext()).thenReturn(context);
+        WebJar webJar = new WebJar(stack, request);
+        webJar.setWebJarUrlProvider(provider);
+        return webJar;
+    }
+
+    @Test
+    public void writesResolvedUrl() {
+        ValueStack stack = mock(ValueStack.class);
+        
when(stack.findString("jquery/jquery.min.js")).thenReturn("jquery/jquery.min.js");
+        HttpServletRequest request = mock(HttpServletRequest.class);
+        WebJarUrlProvider provider = mock(WebJarUrlProvider.class);
+        when(provider.resolveUrl("jquery/jquery.min.js", request))
+            
.thenReturn(Optional.of("/myapp/static/webjars/jquery/3.7.1/jquery.min.js"));
+
+        WebJar webJar = newComponent(stack, request, provider);
+        webJar.setPath("jquery/jquery.min.js");
+        StringWriter writer = new StringWriter();
+
+        webJar.start(writer);
+        webJar.end(writer, "");
+
+        
assertThat(writer).hasToString("/myapp/static/webjars/jquery/3.7.1/jquery.min.js");
+    }
+
+    @Test
+    public void unresolvedPathWritesNothing() {
+        ValueStack stack = mock(ValueStack.class);
+        when(stack.findString("nope/x.js")).thenReturn("nope/x.js");
+        HttpServletRequest request = mock(HttpServletRequest.class);
+        WebJarUrlProvider provider = mock(WebJarUrlProvider.class);
+        when(provider.resolveUrl("nope/x.js", 
request)).thenReturn(Optional.empty());
+
+        WebJar webJar = newComponent(stack, request, provider);
+        webJar.setPath("nope/x.js");
+        StringWriter writer = new StringWriter();
+
+        webJar.start(writer);
+        webJar.end(writer, "");
+
+        assertThat(writer.toString()).isEmpty();
+    }
+}
diff --git 
a/core/src/test/java/org/apache/struts2/dispatcher/DefaultStaticContentLoaderWebJarTest.java
 
b/core/src/test/java/org/apache/struts2/dispatcher/DefaultStaticContentLoaderWebJarTest.java
new file mode 100644
index 000000000..388aff42e
--- /dev/null
+++ 
b/core/src/test/java/org/apache/struts2/dispatcher/DefaultStaticContentLoaderWebJarTest.java
@@ -0,0 +1,119 @@
+/*
+ * 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.struts2.dispatcher;
+
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.junit.Test;
+
+import java.io.ByteArrayOutputStream;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+public class DefaultStaticContentLoaderWebJarTest {
+
+    private final ContentTypeProbe loader = new ContentTypeProbe();
+
+    /** Exposes the protected getContentType for assertion. */
+    static class ContentTypeProbe extends DefaultStaticContentLoader {
+        String type(String name) {
+            return getContentType(name);
+        }
+    }
+
+    @Test
+    public void mapsWebJarAssetTypes() {
+        assertThat(loader.type("x.woff2")).isEqualTo("font/woff2");
+        assertThat(loader.type("x.woff")).isEqualTo("font/woff");
+        assertThat(loader.type("x.ttf")).isEqualTo("font/ttf");
+        assertThat(loader.type("x.otf")).isEqualTo("font/otf");
+        
assertThat(loader.type("x.eot")).isEqualTo("application/vnd.ms-fontobject");
+        assertThat(loader.type("x.svg")).isEqualTo("image/svg+xml");
+        assertThat(loader.type("x.map")).isEqualTo("application/json");
+        assertThat(loader.type("x.json")).isEqualTo("application/json");
+        assertThat(loader.type("x.ico")).isEqualTo("image/x-icon");
+        assertThat(loader.type("x.mjs")).isEqualTo("text/javascript");
+    }
+
+    @Test
+    public void preservesExistingTypes() {
+        assertThat(loader.type("x.js")).isEqualTo("text/javascript");
+        assertThat(loader.type("x.css")).isEqualTo("text/css");
+        assertThat(loader.type("x.png")).isEqualTo("image/png");
+        assertThat(loader.type("x.unknown")).isNull();
+    }
+
+    private DefaultStaticContentLoader newLoader(boolean enabled) {
+        DefaultStaticContentLoader webJarLoader = new 
DefaultStaticContentLoader();
+        webJarLoader.setServeStaticContent("true");
+        webJarLoader.setStaticContentPath("/static");
+        webJarLoader.setServeStaticBrowserCache("true");
+        webJarLoader.setEncoding("UTF-8");
+        org.apache.struts2.webjars.DefaultWebJarUrlProvider provider =
+            new org.apache.struts2.webjars.DefaultWebJarUrlProvider();
+        provider.setEnabled(String.valueOf(enabled));
+        provider.setAllowlist("");
+        provider.setStaticContentPath("/static");
+        webJarLoader.setWebJarUrlProvider(provider);
+        return webJarLoader;
+    }
+
+    @Test
+    public void servesKnownWebJarAssetWithContentType() throws Exception {
+        DefaultStaticContentLoader webJarLoader = newLoader(true);
+        HttpServletRequest request = mock(HttpServletRequest.class);
+        HttpServletResponse response = mock(HttpServletResponse.class);
+        ByteArrayOutputStream captured = new ByteArrayOutputStream();
+        when(response.getOutputStream())
+            .thenReturn(new WebJarTestServletOutputStream(captured));
+
+        
webJarLoader.findStaticResource("/static/webjars/jquery/jquery.min.js", 
request, response);
+
+        verify(response).setContentType("text/javascript");
+        verify(response, never())
+            .sendError(HttpServletResponse.SC_NOT_FOUND);
+        assertThat(captured.size()).isGreaterThan(0);
+    }
+
+    @Test
+    public void unknownWebJarAssetReturns404() throws Exception {
+        DefaultStaticContentLoader webJarLoader = newLoader(true);
+        HttpServletRequest request = mock(HttpServletRequest.class);
+        HttpServletResponse response = mock(HttpServletResponse.class);
+
+        webJarLoader.findStaticResource("/static/webjars/nope/nope.js", 
request, response);
+
+        verify(response).sendError(HttpServletResponse.SC_NOT_FOUND);
+    }
+
+    @Test
+    public void disabledWebJarsReturns404() throws Exception {
+        DefaultStaticContentLoader webJarLoader = newLoader(false);
+        HttpServletRequest request = mock(HttpServletRequest.class);
+        HttpServletResponse response = mock(HttpServletResponse.class);
+
+        
webJarLoader.findStaticResource("/static/webjars/jquery/jquery.min.js", 
request, response);
+
+        verify(response).sendError(HttpServletResponse.SC_NOT_FOUND);
+    }
+}
diff --git 
a/core/src/test/java/org/apache/struts2/dispatcher/WebJarTestServletOutputStream.java
 
b/core/src/test/java/org/apache/struts2/dispatcher/WebJarTestServletOutputStream.java
new file mode 100644
index 000000000..2ba8c8e6c
--- /dev/null
+++ 
b/core/src/test/java/org/apache/struts2/dispatcher/WebJarTestServletOutputStream.java
@@ -0,0 +1,50 @@
+/*
+ * 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.struts2.dispatcher;
+
+import jakarta.servlet.ServletOutputStream;
+import jakarta.servlet.WriteListener;
+
+import java.io.IOException;
+import java.io.OutputStream;
+
+/** Minimal ServletOutputStream backed by an OutputStream, for tests. */
+public class WebJarTestServletOutputStream extends ServletOutputStream {
+
+    private final OutputStream delegate;
+
+    public WebJarTestServletOutputStream(OutputStream delegate) {
+        this.delegate = delegate;
+    }
+
+    @Override
+    public void write(int b) throws IOException {
+        delegate.write(b);
+    }
+
+    @Override
+    public boolean isReady() {
+        return true;
+    }
+
+    @Override
+    public void setWriteListener(WriteListener writeListener) {
+        // no-op
+    }
+}
diff --git 
a/core/src/test/java/org/apache/struts2/webjars/DefaultWebJarUrlProviderTest.java
 
b/core/src/test/java/org/apache/struts2/webjars/DefaultWebJarUrlProviderTest.java
new file mode 100644
index 000000000..6d8d2d37e
--- /dev/null
+++ 
b/core/src/test/java/org/apache/struts2/webjars/DefaultWebJarUrlProviderTest.java
@@ -0,0 +1,110 @@
+/*
+ * 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.struts2.webjars;
+
+import jakarta.servlet.http.HttpServletRequest;
+import org.junit.Before;
+import org.junit.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+public class DefaultWebJarUrlProviderTest {
+
+    private DefaultWebJarUrlProvider provider;
+    private HttpServletRequest request;
+
+    @Before
+    public void setUp() {
+        provider = new DefaultWebJarUrlProvider();
+        provider.setEnabled("true");
+        provider.setAllowlist("");
+        provider.setStaticContentPath("/static");
+        request = mock(HttpServletRequest.class);
+        when(request.getContextPath()).thenReturn("/myapp");
+    }
+
+    @Test
+    public void resolvesKnownResourceToVersionedClasspathPath() {
+        assertThat(provider.resolveResourcePath("jquery/jquery.min.js"))
+            .hasValueSatisfying(p -> assertThat(p)
+                .startsWith("META-INF/resources/webjars/jquery/")
+                .endsWith("/jquery.min.js"));
+    }
+
+    @Test
+    public void resolvesKnownResourceToServableUrl() {
+        assertThat(provider.resolveUrl("jquery/jquery.min.js", request))
+            .hasValueSatisfying(u -> assertThat(u)
+                .startsWith("/myapp/static/webjars/jquery/")
+                .endsWith("/jquery.min.js"));
+    }
+
+    @Test
+    public void unknownWebjarResolvesEmpty() {
+        assertThat(provider.resolveResourcePath("no-such-lib/x.js")).isEmpty();
+        assertThat(provider.resolveUrl("no-such-lib/x.js", request)).isEmpty();
+    }
+
+    @Test
+    public void traversalIsRejected() {
+        
assertThat(provider.resolveResourcePath("jquery/../../../etc/passwd")).isEmpty();
+        
assertThat(provider.resolveResourcePath("../jquery/jquery.min.js")).isEmpty();
+    }
+
+    @Test
+    public void resolveUrlRejectsTraversal() {
+        assertThat(provider.resolveUrl("jquery/../../../etc/passwd", 
request)).isEmpty();
+        assertThat(provider.resolveUrl("../jquery/jquery.min.js", 
request)).isEmpty();
+    }
+
+    @Test
+    public void allowlistBlocksNonListedWebjar() {
+        provider.setAllowlist("bootstrap");
+        
assertThat(provider.resolveResourcePath("jquery/jquery.min.js")).isEmpty();
+    }
+
+    @Test
+    public void allowlistPermitsListedWebjar() {
+        provider.setAllowlist("jquery, bootstrap");
+        
assertThat(provider.resolveResourcePath("jquery/jquery.min.js")).isPresent();
+    }
+
+    @Test
+    public void disabledResolvesEmpty() {
+        provider.setEnabled("false");
+        assertThat(provider.isEnabled()).isFalse();
+        
assertThat(provider.resolveResourcePath("jquery/jquery.min.js")).isEmpty();
+        assertThat(provider.resolveUrl("jquery/jquery.min.js", 
request)).isEmpty();
+    }
+
+    @Test
+    public void rootContextPathIsNotDuplicated() {
+        when(request.getContextPath()).thenReturn("/");
+        assertThat(provider.resolveUrl("jquery/jquery.min.js", request))
+            .hasValueSatisfying(u -> 
assertThat(u).startsWith("/static/webjars/jquery/"));
+    }
+
+    @Test
+    public void blankOrSingleSegmentPathResolvesEmpty() {
+        assertThat(provider.resolveResourcePath("")).isEmpty();
+        assertThat(provider.resolveResourcePath("jquery")).isEmpty();
+    }
+}
diff --git a/docs/superpowers/plans/2026-07-01-WW-5640-webjars-support.md 
b/docs/superpowers/plans/2026-07-01-WW-5640-webjars-support.md
new file mode 100644
index 000000000..888197ae5
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-01-WW-5640-webjars-support.md
@@ -0,0 +1,1396 @@
+# WebJars Support in Struts Core — Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use 
superpowers:subagent-driven-development (recommended) or 
superpowers:executing-plans to implement this plan task-by-task. Steps use 
checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Let Struts core resolve and serve WebJar assets by a version-less 
logical path (`bootstrap/css/bootstrap.min.css` → 
`META-INF/resources/webjars/bootstrap/5.3.8/css/bootstrap.min.css`) through the 
existing static-content pipeline, exposing a `WebJarUrlProvider` seam plus a 
`<s:webjar>` tag / `<@s.webjar>` macro.
+
+**Architecture:** One resolution seam — `WebJarUrlProvider` (default impl 
wraps a singleton `org.webjars.WebJarVersionLocator` from 
`webjars-locator-lite`) — consumed by two callers: a new `/webjars/` branch in 
`DefaultStaticContentLoader` (serving) and a thin `WebJar` component (URL 
string for tags/macros). Resolution is hard-constrained to the 
`META-INF/resources/webjars/` root, honours an optional allowlist and a master 
enable switch, and fails closed (404 / empty output).
+
+**Tech Stack:** Java 17, Maven (multi-module), 
`org.webjars:webjars-locator-lite:1.1.3`, JUnit 5 + AssertJ + Mockito, 
FreeMarker UI templates, Struts container DI (`@Inject`), `struts-annotations` 
(TLD generation).
+
+**Spec:** `docs/superpowers/specs/2026-07-01-webjars-support-design.md` — JIRA 
[WW-5640](https://issues.apache.org/jira/browse/WW-5640).
+
+## Global Constraints
+
+- **Commit prefix:** every commit message starts with `WW-5640 ` and ends with 
the trailer `Co-Authored-By: Claude Opus 4.8 <[email protected]>`.
+- **Module:** all changes are in the `core` module unless a path says 
otherwise.
+- **Java level:** `maven.compiler.release=17`; no APIs beyond Java 17.
+- **Build/test command:** `mvn test -DskipAssembly -pl core` (add 
`-Dtest=...#...` for a single test). Full-module build for dependency changes: 
`mvn -q -pl core -am install -DskipAssembly -DskipTests`.
+- **Dependency footprint:** `webjars-locator-lite` must add exactly one 
transitive jar (`org.jspecify:jspecify`, Apache-2.0). Verify — do not let it 
pull Jackson or a classpath scanner.
+- **Fail closed:** unresolved / disabled / allowlist-blocked / traversal → 404 
when serving, empty output when building URLs. Never fall through to arbitrary 
classpath serving.
+- **Package for new provider classes:** `org.apache.struts2.webjars`. 
Component/tag/model packages are fixed by convention (`components`, 
`views.jsp`, `views.freemarker.tags`).
+- **Config defaults:** `struts.webjars.enabled=true`, 
`struts.webjars.allowlist=` (empty = all). Reuse existing 
`struts.ui.staticContentPath`, `struts.serve.static`, 
`struts.serve.static.browserCache`.
+- **Test framework (CORRECTION, 2026-07-01):** the `core` module uses **JUnit 
4** (`org.junit.Test`, `@Before`) with **AssertJ** and **Mockito** — it has 
**no** JUnit 5 Jupiter engine on the classpath. The test code blocks in Tasks 
3/5/6/7 are written in Jupiter (`org.junit.jupiter.api.*`, `@BeforeEach`, 
`@Nested`) and MUST be translated to JUnit 4 idioms when implemented: 
`org.junit.Test` + `@Before`; no `@BeforeEach`/`@Nested`/`@DisplayName`. Keep 
AssertJ assertions and Mockito verbati [...]
+
+## File Structure
+
+**New files:**
+- `core/src/main/java/org/apache/struts2/webjars/WebJarUrlProvider.java` — 
public interface (the cooperation seam).
+- 
`core/src/main/java/org/apache/struts2/webjars/DefaultWebJarUrlProvider.java` — 
default impl; wraps `WebJarVersionLocator`; normalization + allowlist + URL 
composition.
+- `core/src/main/java/org/apache/struts2/components/WebJar.java` — UI 
component emitting the resolved URL (extends `ContextBean`).
+- `core/src/main/java/org/apache/struts2/views/jsp/WebJarTag.java` — JSP tag 
wrapper.
+- 
`core/src/main/java/org/apache/struts2/views/freemarker/tags/WebJarModel.java` 
— FreeMarker model wrapper.
+- 
`core/src/test/java/org/apache/struts2/webjars/DefaultWebJarUrlProviderTest.java`
 — provider unit tests.
+- 
`core/src/test/java/org/apache/struts2/dispatcher/DefaultStaticContentLoaderWebJarTest.java`
 — serving + content-type tests.
+- `core/src/test/java/org/apache/struts2/components/WebJarTest.java` — 
component/URL-emission test.
+
+**Modified files:**
+- `pom.xml` — version properties for `webjars-locator-lite` and the test 
webjar.
+- `parent/pom.xml` — `<dependencyManagement>` entries for both.
+- `core/pom.xml` — declare the two dependencies (main + test).
+- `core/src/main/java/org/apache/struts2/StrutsConstants.java` — 3 new 
constants.
+- `core/src/main/resources/org/apache/struts2/default.properties` — 2 new 
defaults.
+- `core/src/main/resources/struts-beans.xml` — register the provider bean.
+- 
`core/src/main/java/org/apache/struts2/config/StrutsBeanSelectionProvider.java` 
— bean-selection alias.
+- 
`core/src/main/java/org/apache/struts2/dispatcher/DefaultStaticContentLoader.java`
 — `/webjars/` branch + extended content-type map + `WebJarUrlProvider` 
injection.
+- 
`core/src/main/java/org/apache/struts2/views/freemarker/tags/StrutsModels.java` 
— register `webjar` model.
+
+---
+
+### Task 1: Add `webjars-locator-lite` dependency (+ test webjar)
+
+**Files:**
+- Modify: `pom.xml` (properties block, ~line 116–132)
+- Modify: `parent/pom.xml` (`<dependencyManagement><dependencies>`, ~line 38+)
+- Modify: `core/pom.xml` (`<dependencies>`, ~line 145+)
+
+**Interfaces:**
+- Produces: `org.webjars.WebJarVersionLocator` on the `core` compile 
classpath; a resolvable test webjar (`jquery`) on the `core` test classpath.
+
+- [ ] **Step 1: Add version properties to root `pom.xml`**
+
+In `pom.xml`, inside `<properties>`, in the "dependency versions in 
alphanumeric order" list (after the `<velocity-tools.version>`/`<weld.version>` 
neighbours — keep alphabetical), add:
+
+```xml
+        <webjars-jquery.version>3.7.1</webjars-jquery.version>
+        <webjars-locator-lite.version>1.1.3</webjars-locator-lite.version>
+```
+
+- [ ] **Step 2: Add managed dependencies to `parent/pom.xml`**
+
+In `parent/pom.xml`, inside `<dependencyManagement><dependencies>` (alongside 
the freemarker/caffeine entries around line 45–55), add:
+
+```xml
+            <dependency>
+                <groupId>org.webjars</groupId>
+                <artifactId>webjars-locator-lite</artifactId>
+                <version>${webjars-locator-lite.version}</version>
+            </dependency>
+
+            <dependency>
+                <groupId>org.webjars</groupId>
+                <artifactId>jquery</artifactId>
+                <version>${webjars-jquery.version}</version>
+            </dependency>
+```
+
+- [ ] **Step 3: Declare the dependencies in `core/pom.xml`**
+
+In `core/pom.xml`, inside `<dependencies>` (after the freemarker/caffeine 
block, versionless — versions come from `parent`), add:
+
+```xml
+        <dependency>
+            <groupId>org.webjars</groupId>
+            <artifactId>webjars-locator-lite</artifactId>
+        </dependency>
+
+        <dependency>
+            <groupId>org.webjars</groupId>
+            <artifactId>jquery</artifactId>
+            <scope>test</scope>
+        </dependency>
+```
+
+- [ ] **Step 4: Build and verify the dependency footprint**
+
+Run: `mvn -q -pl core -am install -DskipAssembly -DskipTests`
+Expected: BUILD SUCCESS.
+
+Run: `mvn -q -pl core dependency:tree 
-Dincludes=org.webjars:webjars-locator-lite,org.jspecify:jspecify,com.fasterxml.jackson.core:jackson-databind`
+Expected: `webjars-locator-lite` present with `org.jspecify:jspecify` as its 
only transitive; **no `jackson` line**. If Jackson appears, stop — wrong 
artifact was added.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add pom.xml parent/pom.xml core/pom.xml
+git commit -m "WW-5640 build: add webjars-locator-lite dependency
+
+Co-Authored-By: Claude Opus 4.8 <[email protected]>"
+```
+
+---
+
+### Task 2: Add configuration constants and defaults
+
+**Files:**
+- Modify: `core/src/main/java/org/apache/struts2/StrutsConstants.java` (after 
line 318 and after line 435)
+- Modify: `core/src/main/resources/org/apache/struts2/default.properties` 
(after line 107)
+
+**Interfaces:**
+- Produces: `StrutsConstants.STRUTS_WEBJARS_ENABLED` 
(`"struts.webjars.enabled"`), `STRUTS_WEBJARS_ALLOWLIST` 
(`"struts.webjars.allowlist"`), `STRUTS_WEBJARS_URL_PROVIDER` 
(`"struts.webjars.urlProvider"`); default values in `default.properties`.
+
+- [ ] **Step 1: Add the settings constants**
+
+In `StrutsConstants.java`, immediately after `STRUTS_UI_STATIC_CONTENT_PATH` 
(line 318):
+
+```java
+    /**
+     * Whether WebJars support is enabled (serving and URL building)
+     */
+    public static final String STRUTS_WEBJARS_ENABLED = 
"struts.webjars.enabled";
+
+    /**
+     * Optional comma-separated allowlist of WebJar names permitted to be 
served (empty = all)
+     */
+    public static final String STRUTS_WEBJARS_ALLOWLIST = 
"struts.webjars.allowlist";
+```
+
+- [ ] **Step 2: Add the bean-selection constant**
+
+In `StrutsConstants.java`, immediately after `STRUTS_STATIC_CONTENT_LOADER` 
(line 435):
+
+```java
+    /**
+     * The {@link org.apache.struts2.webjars.WebJarUrlProvider} implementation 
class
+     */
+    public static final String STRUTS_WEBJARS_URL_PROVIDER = 
"struts.webjars.urlProvider";
+```
+
+- [ ] **Step 3: Add defaults to `default.properties`**
+
+In `default.properties`, after line 107 
(`struts.serve.static.browserCache=true`):
+
+```properties
+
+### WebJars support
+### Master switch for resolving/serving WebJar assets under 
<staticContentPath>/webjars/**
+struts.webjars.enabled=true
+### Optional comma-separated allowlist of WebJar names (empty = all WebJars on 
the classpath)
+struts.webjars.allowlist=
+```
+
+- [ ] **Step 4: Compile to verify**
+
+Run: `mvn -q -pl core compile -DskipAssembly`
+Expected: BUILD SUCCESS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add core/src/main/java/org/apache/struts2/StrutsConstants.java 
core/src/main/resources/org/apache/struts2/default.properties
+git commit -m "WW-5640 feat: add webjars config constants and defaults
+
+Co-Authored-By: Claude Opus 4.8 <[email protected]>"
+```
+
+---
+
+### Task 3: `WebJarUrlProvider` interface + `DefaultWebJarUrlProvider` (TDD)
+
+**Files:**
+- Create: 
`core/src/main/java/org/apache/struts2/webjars/WebJarUrlProvider.java`
+- Create: 
`core/src/main/java/org/apache/struts2/webjars/DefaultWebJarUrlProvider.java`
+- Test: 
`core/src/test/java/org/apache/struts2/webjars/DefaultWebJarUrlProviderTest.java`
+
+**Interfaces:**
+- Consumes: `org.webjars.WebJarVersionLocator` (methods `String path(String 
name, String filePath)`, `String fullPath(String name, String filePath)`, both 
return `null` when unresolved; constant 
`WebJarVersionLocator.WEBJARS_PATH_PREFIX == "META-INF/resources/webjars"`); 
`StrutsConstants` from Task 2; 
`StaticContentLoader.Validator.validateStaticContentPath(String)`.
+- Produces:
+  - `WebJarUrlProvider.resolveResourcePath(String logicalPath) -> 
Optional<String>` (classpath resource under `META-INF/resources/webjars/…`, for 
serving).
+  - `WebJarUrlProvider.resolveUrl(String logicalPath, HttpServletRequest 
request) -> Optional<String>` (servable URL: contextPath + staticContentPath + 
`/webjars/` + versioned path).
+  - `WebJarUrlProvider.isEnabled() -> boolean`.
+
+- [ ] **Step 1: Write the interface**
+
+Create `WebJarUrlProvider.java`:
+
+```java
+/*
+ * 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.struts2.webjars;
+
+import jakarta.servlet.http.HttpServletRequest;
+
+import java.util.Optional;
+
+/**
+ * Resolves version-less WebJar logical paths (e.g. {@code 
bootstrap/css/bootstrap.min.css}) to either a
+ * concrete classpath resource under {@code META-INF/resources/webjars/} (for 
serving) or a servable URL
+ * (for tags/macros). Resolution is constrained to the WebJars root, honours 
an optional allowlist and the
+ * {@code struts.webjars.enabled} switch, and fails closed (empty result) when 
unresolved or blocked.
+ */
+public interface WebJarUrlProvider {
+
+    /**
+     * @param logicalPath version-less path such as {@code 
bootstrap/css/bootstrap.min.css}
+     * @return the concrete classpath resource path (e.g.
+     *         {@code 
META-INF/resources/webjars/bootstrap/5.3.8/css/bootstrap.min.css}), or empty
+     */
+    Optional<String> resolveResourcePath(String logicalPath);
+
+    /**
+     * @param logicalPath version-less path such as {@code 
bootstrap/css/bootstrap.min.css}
+     * @param request     the current request (used for the servlet context 
path)
+     * @return a servable URL, or empty
+     */
+    Optional<String> resolveUrl(String logicalPath, HttpServletRequest 
request);
+
+    /**
+     * @return whether WebJars support is enabled
+     */
+    boolean isEnabled();
+}
+```
+
+- [ ] **Step 2: Write the failing tests**
+
+Create `DefaultWebJarUrlProviderTest.java` (the `jquery` test webjar from Task 
1 provides a real, resolvable WebJar):
+
+```java
+/*
+ * 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.struts2.webjars;
+
+import jakarta.servlet.http.HttpServletRequest;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class DefaultWebJarUrlProviderTest {
+
+    private DefaultWebJarUrlProvider provider;
+    private HttpServletRequest request;
+
+    @BeforeEach
+    void setUp() {
+        provider = new DefaultWebJarUrlProvider();
+        provider.setEnabled("true");
+        provider.setAllowlist("");
+        provider.setStaticContentPath("/static");
+        request = mock(HttpServletRequest.class);
+        when(request.getContextPath()).thenReturn("/myapp");
+    }
+
+    @Test
+    void resolvesKnownResourceToVersionedClasspathPath() {
+        assertThat(provider.resolveResourcePath("jquery/jquery.min.js"))
+            .hasValueSatisfying(p -> assertThat(p)
+                .startsWith("META-INF/resources/webjars/jquery/")
+                .endsWith("/jquery.min.js"));
+    }
+
+    @Test
+    void resolvesKnownResourceToServableUrl() {
+        assertThat(provider.resolveUrl("jquery/jquery.min.js", request))
+            .hasValueSatisfying(u -> assertThat(u)
+                .startsWith("/myapp/static/webjars/jquery/")
+                .endsWith("/jquery.min.js"));
+    }
+
+    @Test
+    void unknownWebjarResolvesEmpty() {
+        assertThat(provider.resolveResourcePath("no-such-lib/x.js")).isEmpty();
+        assertThat(provider.resolveUrl("no-such-lib/x.js", request)).isEmpty();
+    }
+
+    @Test
+    void traversalIsRejected() {
+        
assertThat(provider.resolveResourcePath("jquery/../../../etc/passwd")).isEmpty();
+        
assertThat(provider.resolveResourcePath("../jquery/jquery.min.js")).isEmpty();
+    }
+
+    @Test
+    void allowlistBlocksNonListedWebjar() {
+        provider.setAllowlist("bootstrap");
+        
assertThat(provider.resolveResourcePath("jquery/jquery.min.js")).isEmpty();
+    }
+
+    @Test
+    void allowlistPermitsListedWebjar() {
+        provider.setAllowlist("jquery, bootstrap");
+        
assertThat(provider.resolveResourcePath("jquery/jquery.min.js")).isPresent();
+    }
+
+    @Test
+    void disabledResolvesEmpty() {
+        provider.setEnabled("false");
+        assertThat(provider.isEnabled()).isFalse();
+        
assertThat(provider.resolveResourcePath("jquery/jquery.min.js")).isEmpty();
+        assertThat(provider.resolveUrl("jquery/jquery.min.js", 
request)).isEmpty();
+    }
+
+    @Test
+    void rootContextPathIsNotDuplicated() {
+        when(request.getContextPath()).thenReturn("/");
+        assertThat(provider.resolveUrl("jquery/jquery.min.js", request))
+            .hasValueSatisfying(u -> 
assertThat(u).startsWith("/static/webjars/jquery/"));
+    }
+
+    @Test
+    void blankOrSingleSegmentPathResolvesEmpty() {
+        assertThat(provider.resolveResourcePath("")).isEmpty();
+        assertThat(provider.resolveResourcePath("jquery")).isEmpty();
+    }
+}
+```
+
+- [ ] **Step 3: Run tests to verify they fail**
+
+Run: `mvn test -DskipAssembly -pl core -Dtest=DefaultWebJarUrlProviderTest`
+Expected: FAIL — `DefaultWebJarUrlProvider` does not exist / does not compile.
+
+- [ ] **Step 4: Write the implementation**
+
+Create `DefaultWebJarUrlProvider.java`:
+
+```java
+/*
+ * 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.struts2.webjars;
+
+import jakarta.servlet.http.HttpServletRequest;
+import org.apache.commons.lang3.BooleanUtils;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.apache.struts2.StrutsConstants;
+import org.apache.struts2.dispatcher.StaticContentLoader;
+import org.apache.struts2.inject.Inject;
+import org.webjars.WebJarVersionLocator;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Optional;
+import java.util.Set;
+
+/**
+ * Default {@link WebJarUrlProvider} backed by a singleton {@link 
WebJarVersionLocator}
+ * (from {@code webjars-locator-lite}). Thread-safe.
+ */
+public class DefaultWebJarUrlProvider implements WebJarUrlProvider {
+
+    private static final Logger LOG = 
LogManager.getLogger(DefaultWebJarUrlProvider.class);
+
+    private static final String WEBJARS_URL_SEGMENT = "/webjars/";
+
+    private final WebJarVersionLocator locator = new WebJarVersionLocator();
+
+    private boolean enabled = true;
+    private Set<String> allowlist = Collections.emptySet();
+    private String uiStaticContentPath = 
StaticContentLoader.DEFAULT_STATIC_CONTENT_PATH;
+
+    @Inject(value = StrutsConstants.STRUTS_WEBJARS_ENABLED, required = false)
+    public void setEnabled(String enabled) {
+        this.enabled = BooleanUtils.toBoolean(enabled);
+    }
+
+    @Inject(value = StrutsConstants.STRUTS_WEBJARS_ALLOWLIST, required = false)
+    public void setAllowlist(String allowlist) {
+        Set<String> names = new HashSet<>();
+        if (StringUtils.isNotBlank(allowlist)) {
+            for (String name : allowlist.split(",")) {
+                String trimmed = name.trim();
+                if (!trimmed.isEmpty()) {
+                    names.add(trimmed);
+                }
+            }
+        }
+        this.allowlist = Collections.unmodifiableSet(names);
+    }
+
+    @Inject(StrutsConstants.STRUTS_UI_STATIC_CONTENT_PATH)
+    public void setStaticContentPath(String uiStaticContentPath) {
+        this.uiStaticContentPath = 
StaticContentLoader.Validator.validateStaticContentPath(uiStaticContentPath);
+    }
+
+    @Override
+    public boolean isEnabled() {
+        return enabled;
+    }
+
+    @Override
+    public Optional<String> resolveResourcePath(String logicalPath) {
+        String[] parts = split(logicalPath);
+        if (parts == null) {
+            return Optional.empty();
+        }
+        String full = locator.fullPath(parts[0], parts[1]);
+        if (full == null || 
!full.startsWith(WebJarVersionLocator.WEBJARS_PATH_PREFIX + "/")) {
+            return Optional.empty();
+        }
+        return Optional.of(full);
+    }
+
+    @Override
+    public Optional<String> resolveUrl(String logicalPath, HttpServletRequest 
request) {
+        String[] parts = split(logicalPath);
+        if (parts == null) {
+            return Optional.empty();
+        }
+        String versioned = locator.path(parts[0], parts[1]);
+        if (versioned == null) {
+            return Optional.empty();
+        }
+        StringBuilder url = new StringBuilder();
+        String contextPath = request.getContextPath();
+        if (StringUtils.isNotEmpty(contextPath) && !"/".equals(contextPath)) {
+            url.append(contextPath);
+        }
+        
url.append(uiStaticContentPath).append(WEBJARS_URL_SEGMENT).append(versioned);
+        return Optional.of(url.toString());
+    }
+
+    /**
+     * Normalize, validate and split a logical path into {webJarName, 
filePath}.
+     *
+     * @return a two-element array, or {@code null} if disabled, blank, 
single-segment,
+     *         traversal-tainted, or allowlist-blocked
+     */
+    private String[] split(String logicalPath) {
+        if (!enabled || StringUtils.isBlank(logicalPath)) {
+            return null;
+        }
+        String normalized = StringUtils.stripStart(logicalPath, "/");
+        if (normalized.contains("\\")) {
+            return null;
+        }
+        for (String segment : normalized.split("/")) {
+            if (segment.equals("..") || segment.equals(".")) {
+                LOG.debug("Rejecting WebJar path with traversal segment: {}", 
logicalPath);
+                return null;
+            }
+        }
+        int slash = normalized.indexOf('/');
+        if (slash < 1 || slash == normalized.length() - 1) {
+            return null;
+        }
+        String webJarName = normalized.substring(0, slash);
+        String filePath = normalized.substring(slash + 1);
+        if (!isAllowed(webJarName)) {
+            LOG.debug("WebJar '{}' is not on the allowlist", webJarName);
+            return null;
+        }
+        return new String[]{webJarName, filePath};
+    }
+
+    private boolean isAllowed(String webJarName) {
+        return allowlist.isEmpty() || allowlist.contains(webJarName);
+    }
+}
+```
+
+Note: `Arrays` import is used by no code above — remove it if your IDE flags 
it; it is listed only so the import block compiles cleanly if you extend 
allowlist parsing. (Prefer removing unused imports before commit.)
+
+- [ ] **Step 5: Run tests to verify they pass**
+
+Run: `mvn test -DskipAssembly -pl core -Dtest=DefaultWebJarUrlProviderTest`
+Expected: PASS (all 9 tests).
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add core/src/main/java/org/apache/struts2/webjars/ 
core/src/test/java/org/apache/struts2/webjars/
+git commit -m "WW-5640 feat: add WebJarUrlProvider resolution seam
+
+Co-Authored-By: Claude Opus 4.8 <[email protected]>"
+```
+
+---
+
+### Task 4: Register the provider bean in the container
+
+**Files:**
+- Modify: `core/src/main/resources/struts-beans.xml` (after line 209)
+- Modify: 
`core/src/main/java/org/apache/struts2/config/StrutsBeanSelectionProvider.java` 
(import + after the `StaticContentLoader` alias, ~line 432)
+
+**Interfaces:**
+- Consumes: `WebJarUrlProvider` (Task 3), 
`StrutsConstants.STRUTS_WEBJARS_URL_PROVIDER` (Task 2).
+- Produces: a container-resolvable `WebJarUrlProvider` bean (`name="struts"`), 
injectable via `@Inject`/`Container.getInstance(WebJarUrlProvider.class)`.
+
+- [ ] **Step 1: Register the default bean**
+
+In `struts-beans.xml`, immediately after the `StaticContentLoader` bean (line 
208–209):
+
+```xml
+    <bean type="org.apache.struts2.webjars.WebJarUrlProvider"
+          class="org.apache.struts2.webjars.DefaultWebJarUrlProvider" 
name="struts"/>
+```
+
+- [ ] **Step 2: Add the bean-selection alias**
+
+In `StrutsBeanSelectionProvider.java`, add the import (with the other 
`org.apache.struts2.*` imports):
+
+```java
+import org.apache.struts2.webjars.WebJarUrlProvider;
+```
+
+Then, in `register(...)`, immediately after the `StaticContentLoader` alias 
line (`alias(StaticContentLoader.class, 
StrutsConstants.STRUTS_STATIC_CONTENT_LOADER, builder, props);`):
+
+```java
+        alias(WebJarUrlProvider.class, 
StrutsConstants.STRUTS_WEBJARS_URL_PROVIDER, builder, props);
+```
+
+- [ ] **Step 3: Build to verify wiring**
+
+Run: `mvn test -DskipAssembly -pl core -Dtest=StrutsBeanSelectionProviderTest`
+Expected: PASS (if this test does not exist in the module, run `mvn -q -pl 
core compile -DskipAssembly` and expect BUILD SUCCESS instead).
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add core/src/main/resources/struts-beans.xml 
core/src/main/java/org/apache/struts2/config/StrutsBeanSelectionProvider.java
+git commit -m "WW-5640 feat: register WebJarUrlProvider bean
+
+Co-Authored-By: Claude Opus 4.8 <[email protected]>"
+```
+
+---
+
+### Task 5: Extend static content-type map for WebJar asset types (TDD)
+
+**Files:**
+- Modify: 
`core/src/main/java/org/apache/struts2/dispatcher/DefaultStaticContentLoader.java`
 (`getContentType`, lines 330–350)
+- Test: 
`core/src/test/java/org/apache/struts2/dispatcher/DefaultStaticContentLoaderWebJarTest.java`
 (create; content-type cases first, serving cases added in Task 6)
+
+**Interfaces:**
+- Produces: `DefaultStaticContentLoader.getContentType(String)` returns 
correct MIME types for `.woff`, `.woff2`, `.ttf`, `.eot`, `.otf`, `.svg`, 
`.map`, `.json`, `.ico`, `.mjs` (plus existing types).
+
+- [ ] **Step 1: Write the failing test**
+
+Create `DefaultStaticContentLoaderWebJarTest.java`:
+
+```java
+/*
+ * 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.struts2.dispatcher;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class DefaultStaticContentLoaderWebJarTest {
+
+    private final ContentTypeProbe loader = new ContentTypeProbe();
+
+    /** Exposes the protected getContentType for assertion. */
+    static class ContentTypeProbe extends DefaultStaticContentLoader {
+        String type(String name) {
+            return getContentType(name);
+        }
+    }
+
+    @Test
+    void mapsWebJarAssetTypes() {
+        assertThat(loader.type("x.woff2")).isEqualTo("font/woff2");
+        assertThat(loader.type("x.woff")).isEqualTo("font/woff");
+        assertThat(loader.type("x.ttf")).isEqualTo("font/ttf");
+        assertThat(loader.type("x.otf")).isEqualTo("font/otf");
+        
assertThat(loader.type("x.eot")).isEqualTo("application/vnd.ms-fontobject");
+        assertThat(loader.type("x.svg")).isEqualTo("image/svg+xml");
+        assertThat(loader.type("x.map")).isEqualTo("application/json");
+        assertThat(loader.type("x.json")).isEqualTo("application/json");
+        assertThat(loader.type("x.ico")).isEqualTo("image/x-icon");
+        assertThat(loader.type("x.mjs")).isEqualTo("text/javascript");
+    }
+
+    @Test
+    void preservesExistingTypes() {
+        assertThat(loader.type("x.js")).isEqualTo("text/javascript");
+        assertThat(loader.type("x.css")).isEqualTo("text/css");
+        assertThat(loader.type("x.png")).isEqualTo("image/png");
+        assertThat(loader.type("x.unknown")).isNull();
+    }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `mvn test -DskipAssembly -pl core 
-Dtest=DefaultStaticContentLoaderWebJarTest#mapsWebJarAssetTypes`
+Expected: FAIL — e.g. `.woff2` returns `null`.
+
+- [ ] **Step 3: Extend `getContentType`**
+
+In `DefaultStaticContentLoader.getContentType`, replace the existing `if/else` 
chain body (lines 333–349) with the extended version (keeps all existing 
entries, adds new ones):
+
+```java
+        if (name.endsWith(".js") || name.endsWith(".mjs")) {
+            return "text/javascript";
+        } else if (name.endsWith(".css")) {
+            return "text/css";
+        } else if (name.endsWith(".html")) {
+            return "text/html";
+        } else if (name.endsWith(".txt")) {
+            return "text/plain";
+        } else if (name.endsWith(".gif")) {
+            return "image/gif";
+        } else if (name.endsWith(".jpg") || name.endsWith(".jpeg")) {
+            return "image/jpeg";
+        } else if (name.endsWith(".png")) {
+            return "image/png";
+        } else if (name.endsWith(".svg")) {
+            return "image/svg+xml";
+        } else if (name.endsWith(".ico")) {
+            return "image/x-icon";
+        } else if (name.endsWith(".woff2")) {
+            return "font/woff2";
+        } else if (name.endsWith(".woff")) {
+            return "font/woff";
+        } else if (name.endsWith(".ttf")) {
+            return "font/ttf";
+        } else if (name.endsWith(".otf")) {
+            return "font/otf";
+        } else if (name.endsWith(".eot")) {
+            return "application/vnd.ms-fontobject";
+        } else if (name.endsWith(".json") || name.endsWith(".map")) {
+            return "application/json";
+        } else {
+            return null;
+        }
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `mvn test -DskipAssembly -pl core 
-Dtest=DefaultStaticContentLoaderWebJarTest`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add 
core/src/main/java/org/apache/struts2/dispatcher/DefaultStaticContentLoader.java
 
core/src/test/java/org/apache/struts2/dispatcher/DefaultStaticContentLoaderWebJarTest.java
+git commit -m "WW-5640 feat: extend static content-type map for webjar assets
+
+Co-Authored-By: Claude Opus 4.8 <[email protected]>"
+```
+
+---
+
+### Task 6: Serve `/webjars/**` through `DefaultStaticContentLoader` (TDD)
+
+**Files:**
+- Modify: 
`core/src/main/java/org/apache/struts2/dispatcher/DefaultStaticContentLoader.java`
 (imports; new field + `@Inject` setter; refactor 404 block; 
`findStaticResource` branch; new `findWebJarResource`)
+- Test: 
`core/src/test/java/org/apache/struts2/dispatcher/DefaultStaticContentLoaderWebJarTest.java`
 (add serving cases)
+
+**Interfaces:**
+- Consumes: `WebJarUrlProvider.resolveResourcePath(String)` (Task 3); existing 
`findResource(String)`, `process(InputStream, String, ...)`, 
`cleanupPath(String)`.
+- Produces: serving of `<staticContentPath>/webjars/<name>/<path>` → 200 with 
the versioned classpath resource; unresolved → 404.
+
+- [ ] **Step 1: Add the failing serving tests**
+
+Append to `DefaultStaticContentLoaderWebJarTest.java` (inside the class):
+
+```java
+    @org.junit.jupiter.api.Nested
+    class Serving {
+
+        private DefaultStaticContentLoader newLoader(boolean enabled) {
+            DefaultStaticContentLoader loader = new 
DefaultStaticContentLoader();
+            loader.setServeStaticContent("true");
+            loader.setStaticContentPath("/static");
+            loader.setServeStaticBrowserCache("true");
+            loader.setEncoding("UTF-8");
+            org.apache.struts2.webjars.DefaultWebJarUrlProvider provider =
+                new org.apache.struts2.webjars.DefaultWebJarUrlProvider();
+            provider.setEnabled(String.valueOf(enabled));
+            provider.setAllowlist("");
+            provider.setStaticContentPath("/static");
+            loader.setWebJarUrlProvider(provider);
+            return loader;
+        }
+
+        @Test
+        void servesKnownWebJarAssetWithContentType() throws Exception {
+            DefaultStaticContentLoader loader = newLoader(true);
+            jakarta.servlet.http.HttpServletRequest request =
+                
org.mockito.Mockito.mock(jakarta.servlet.http.HttpServletRequest.class);
+            jakarta.servlet.http.HttpServletResponse response =
+                
org.mockito.Mockito.mock(jakarta.servlet.http.HttpServletResponse.class);
+            java.io.ByteArrayOutputStream captured = new 
java.io.ByteArrayOutputStream();
+            org.mockito.Mockito.when(response.getOutputStream())
+                .thenReturn(new 
org.apache.struts2.dispatcher.WebJarTestServletOutputStream(captured));
+
+            loader.findStaticResource("/static/webjars/jquery/jquery.min.js", 
request, response);
+
+            
org.mockito.Mockito.verify(response).setContentType("text/javascript");
+            org.mockito.Mockito.verify(response, org.mockito.Mockito.never())
+                
.sendError(jakarta.servlet.http.HttpServletResponse.SC_NOT_FOUND);
+            assertThat(captured.size()).isGreaterThan(0);
+        }
+
+        @Test
+        void unknownWebJarAssetReturns404() throws Exception {
+            DefaultStaticContentLoader loader = newLoader(true);
+            jakarta.servlet.http.HttpServletRequest request =
+                
org.mockito.Mockito.mock(jakarta.servlet.http.HttpServletRequest.class);
+            jakarta.servlet.http.HttpServletResponse response =
+                
org.mockito.Mockito.mock(jakarta.servlet.http.HttpServletResponse.class);
+
+            loader.findStaticResource("/static/webjars/nope/nope.js", request, 
response);
+
+            org.mockito.Mockito.verify(response)
+                
.sendError(jakarta.servlet.http.HttpServletResponse.SC_NOT_FOUND);
+        }
+
+        @Test
+        void disabledWebJarsReturns404() throws Exception {
+            DefaultStaticContentLoader loader = newLoader(false);
+            jakarta.servlet.http.HttpServletRequest request =
+                
org.mockito.Mockito.mock(jakarta.servlet.http.HttpServletRequest.class);
+            jakarta.servlet.http.HttpServletResponse response =
+                
org.mockito.Mockito.mock(jakarta.servlet.http.HttpServletResponse.class);
+
+            loader.findStaticResource("/static/webjars/jquery/jquery.min.js", 
request, response);
+
+            org.mockito.Mockito.verify(response)
+                
.sendError(jakarta.servlet.http.HttpServletResponse.SC_NOT_FOUND);
+        }
+    }
+```
+
+Create the minimal `ServletOutputStream` test helper 
`core/src/test/java/org/apache/struts2/dispatcher/WebJarTestServletOutputStream.java`:
+
+```java
+/*
+ * 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.struts2.dispatcher;
+
+import jakarta.servlet.ServletOutputStream;
+import jakarta.servlet.WriteListener;
+
+import java.io.IOException;
+import java.io.OutputStream;
+
+/** Minimal ServletOutputStream backed by an OutputStream, for tests. */
+public class WebJarTestServletOutputStream extends ServletOutputStream {
+
+    private final OutputStream delegate;
+
+    public WebJarTestServletOutputStream(OutputStream delegate) {
+        this.delegate = delegate;
+    }
+
+    @Override
+    public void write(int b) throws IOException {
+        delegate.write(b);
+    }
+
+    @Override
+    public boolean isReady() {
+        return true;
+    }
+
+    @Override
+    public void setWriteListener(WriteListener writeListener) {
+        // no-op
+    }
+}
+```
+
+- [ ] **Step 2: Run the serving tests to verify they fail**
+
+Run: `mvn test -DskipAssembly -pl core 
-Dtest=DefaultStaticContentLoaderWebJarTest`
+Expected: FAIL — `setWebJarUrlProvider` does not exist / `/webjars/` not 
handled.
+
+- [ ] **Step 3: Wire the provider and add the branch**
+
+In `DefaultStaticContentLoader.java`, add imports (with the existing imports):
+
+```java
+import java.util.Optional;
+import org.apache.struts2.webjars.WebJarUrlProvider;
+```
+
+Add a field (near `encoding`/`devMode`, ~line 108) and its injection setter:
+
+```java
+    protected WebJarUrlProvider webJarUrlProvider;
+
+    @Inject
+    public void setWebJarUrlProvider(WebJarUrlProvider webJarUrlProvider) {
+        this.webJarUrlProvider = webJarUrlProvider;
+    }
+```
+
+Add a class constant (near the top of the class body):
+
+```java
+    protected static final String WEBJARS_REQUEST_PREFIX = "/webjars/";
+```
+
+Refactor `findStaticResource` (lines 209–243). Replace the whole method with:
+
+```java
+    public void findStaticResource(String path, HttpServletRequest request, 
HttpServletResponse response)
+        throws IOException {
+        String name = cleanupPath(path);
+
+        if (name.startsWith(WEBJARS_REQUEST_PREFIX)) {
+            if (!findWebJarResource(name, path, request, response)) {
+                sendNotFound(response);
+            }
+            return;
+        }
+
+        for (String pathPrefix : pathPrefixes) {
+            URL resourceUrl = findResource(buildPath(name, pathPrefix));
+            if (resourceUrl != null) {
+                InputStream is = null;
+                try {
+                    //check that the resource path is under the pathPrefix path
+                    String pathEnding = buildPath(name, pathPrefix);
+                    if (resourceUrl.getFile().endsWith(pathEnding))
+                        is = resourceUrl.openStream();
+                } catch (IOException ex) {
+                    // just ignore it
+                    continue;
+                }
+
+                //not inside the try block, as this could throw IOExceptions 
also
+                if (is != null) {
+                    process(is, path, request, response);
+                    return;
+                }
+            }
+        }
+
+        sendNotFound(response);
+    }
+
+    /**
+     * Resolve and serve a WebJar asset requested under {@code 
<staticContentPath>/webjars/**}.
+     *
+     * @param name    the request path with the static-content prefix 
stripped, e.g. {@code /webjars/jquery/jquery.min.js}
+     * @param path    the original request path (used for content-type 
detection)
+     * @return true if the asset was resolved and streamed; false otherwise 
(caller sends 404)
+     */
+    protected boolean findWebJarResource(String name, String path, 
HttpServletRequest request, HttpServletResponse response)
+        throws IOException {
+        String logicalPath = name.substring(WEBJARS_REQUEST_PREFIX.length());
+        Optional<String> resource = 
webJarUrlProvider.resolveResourcePath(logicalPath);
+        if (resource.isEmpty()) {
+            return false;
+        }
+        URL resourceUrl = findResource(resource.get());
+        if (resourceUrl == null) {
+            return false;
+        }
+        process(resourceUrl.openStream(), path, request, response);
+        return true;
+    }
+
+    protected void sendNotFound(HttpServletResponse response) {
+        try {
+            response.sendError(HttpServletResponse.SC_NOT_FOUND);
+        } catch (IOException e1) {
+            // we're already sending an error, not much else we can do if more 
stuff breaks
+            LOG.warn("Unable to send error response, code: {};", 
HttpServletResponse.SC_NOT_FOUND, e1);
+        } catch (IllegalStateException ise) {
+            // Log illegalstate instead of passing unrecoverable exception to 
calling thread
+            LOG.warn("Unable to send error response, code: {}; isCommitted: 
{};", HttpServletResponse.SC_NOT_FOUND, response.isCommitted(), ise);
+        }
+    }
+```
+
+(The `sendNotFound` body is lifted verbatim from the old inline 404 block so 
behaviour is unchanged for non-WebJar paths.)
+
+- [ ] **Step 4: Run all loader tests to verify they pass**
+
+Run: `mvn test -DskipAssembly -pl core 
-Dtest=DefaultStaticContentLoaderWebJarTest`
+Expected: PASS (content-type + all three serving tests).
+
+- [ ] **Step 5: Run the full dispatcher test package for regressions**
+
+Run: `mvn test -DskipAssembly -pl core -Dtest=org.apache.struts2.dispatcher.*`
+Expected: PASS (no regression in existing static-content behaviour).
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add 
core/src/main/java/org/apache/struts2/dispatcher/DefaultStaticContentLoader.java
 core/src/test/java/org/apache/struts2/dispatcher/
+git commit -m "WW-5640 feat: serve webjar assets via static content loader
+
+Co-Authored-By: Claude Opus 4.8 <[email protected]>"
+```
+
+---
+
+### Task 7: `<s:webjar>` component + JSP tag + FreeMarker macro (TDD)
+
+**Files:**
+- Create: `core/src/main/java/org/apache/struts2/components/WebJar.java`
+- Create: `core/src/main/java/org/apache/struts2/views/jsp/WebJarTag.java`
+- Create: 
`core/src/main/java/org/apache/struts2/views/freemarker/tags/WebJarModel.java`
+- Modify: 
`core/src/main/java/org/apache/struts2/views/freemarker/tags/StrutsModels.java` 
(field + getter)
+- Test: `core/src/test/java/org/apache/struts2/components/WebJarTest.java`
+
+**Interfaces:**
+- Consumes: `WebJarUrlProvider.resolveUrl(String, HttpServletRequest)` (Task 
3); `ContextBean` (`var`, `putInContext`, `findString`); `Component` (`@Inject` 
processed by `container.inject`).
+- Produces: `<s:webjar path="…"/>` and `<@s.webjar path="…"/>` emitting the 
resolved URL string, or storing it in `var`; empty output when unresolved.
+
+- [ ] **Step 1: Write the failing component test**
+
+Create `WebJarTest.java`:
+
+```java
+/*
+ * 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.struts2.components;
+
+import jakarta.servlet.http.HttpServletRequest;
+import org.apache.struts2.util.ValueStack;
+import org.apache.struts2.util.ValueStackFactory;
+import org.apache.struts2.config.ConfigurationManager;
+import org.apache.struts2.config.providers.XmlConfigurationProvider;
+import org.apache.struts2.inject.Container;
+import org.apache.struts2.webjars.WebJarUrlProvider;
+import org.junit.jupiter.api.Test;
+
+import java.io.StringWriter;
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class WebJarTest {
+
+    private WebJar newComponent(ValueStack stack, HttpServletRequest request, 
WebJarUrlProvider provider) {
+        WebJar webJar = new WebJar(stack, request);
+        webJar.setWebJarUrlProvider(provider);
+        return webJar;
+    }
+
+    @Test
+    void writesResolvedUrl() {
+        ValueStack stack = mock(ValueStack.class);
+        
when(stack.findString("jquery/jquery.min.js")).thenReturn("jquery/jquery.min.js");
+        HttpServletRequest request = mock(HttpServletRequest.class);
+        WebJarUrlProvider provider = mock(WebJarUrlProvider.class);
+        when(provider.resolveUrl("jquery/jquery.min.js", request))
+            
.thenReturn(Optional.of("/myapp/static/webjars/jquery/3.7.1/jquery.min.js"));
+
+        WebJar webJar = newComponent(stack, request, provider);
+        webJar.setPath("jquery/jquery.min.js");
+        StringWriter writer = new StringWriter();
+
+        webJar.start(writer);
+        webJar.end(writer, "");
+
+        
assertThat(writer.toString()).isEqualTo("/myapp/static/webjars/jquery/3.7.1/jquery.min.js");
+    }
+
+    @Test
+    void unresolvedPathWritesNothing() {
+        ValueStack stack = mock(ValueStack.class);
+        when(stack.findString("nope/x.js")).thenReturn("nope/x.js");
+        HttpServletRequest request = mock(HttpServletRequest.class);
+        WebJarUrlProvider provider = mock(WebJarUrlProvider.class);
+        when(provider.resolveUrl("nope/x.js", 
request)).thenReturn(Optional.empty());
+
+        WebJar webJar = newComponent(stack, request, provider);
+        webJar.setPath("nope/x.js");
+        StringWriter writer = new StringWriter();
+
+        webJar.start(writer);
+        webJar.end(writer, "");
+
+        assertThat(writer.toString()).isEmpty();
+    }
+}
+```
+
+> If `stack.getContext()` is dereferenced by `ContextBean`/`Component` during 
`end`, stub it: `when(stack.getContext()).thenReturn(new 
org.apache.struts2.util.StrutsContext());` — add only if a NullPointerException 
surfaces at run time.
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `mvn test -DskipAssembly -pl core -Dtest=WebJarTest`
+Expected: FAIL — `WebJar` does not exist.
+
+- [ ] **Step 3: Write the component**
+
+Create `WebJar.java`:
+
+```java
+/*
+ * 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.struts2.components;
+
+import jakarta.servlet.http.HttpServletRequest;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.apache.struts2.inject.Inject;
+import org.apache.struts2.util.ValueStack;
+import org.apache.struts2.views.annotations.StrutsTag;
+import org.apache.struts2.views.annotations.StrutsTagAttribute;
+import org.apache.struts2.webjars.WebJarUrlProvider;
+
+import java.io.IOException;
+import java.io.Writer;
+import java.util.Optional;
+
+/**
+ * <p>Resolves a version-less WebJar resource path to a servable URL and 
writes it to the output
+ * (or stores it in a variable when {@code var} is set). Compose it with 
{@code <s:script>}/{@code <s:link>}
+ * or a raw {@code <link>}/{@code <script>} element.</p>
+ *
+ * <b>Examples</b>
+ * <pre>
+ *   &lt;link rel="stylesheet" href="&lt;s:webjar 
path="bootstrap/css/bootstrap.min.css"/&gt;"&gt;
+ *   &lt;@s.webjar path="jquery/jquery.min.js"/&gt;
+ * </pre>
+ */
+@StrutsTag(
+    name = "webjar",
+    tldTagClass = "org.apache.struts2.views.jsp.WebJarTag",
+    description = "Resolve a version-less WebJar resource path to a servable 
URL")
+public class WebJar extends ContextBean {
+
+    private static final Logger LOG = LogManager.getLogger(WebJar.class);
+
+    protected String path;
+
+    private final HttpServletRequest request;
+    private WebJarUrlProvider webJarUrlProvider;
+
+    public WebJar(ValueStack stack, HttpServletRequest request) {
+        super(stack);
+        this.request = request;
+    }
+
+    @Inject
+    public void setWebJarUrlProvider(WebJarUrlProvider webJarUrlProvider) {
+        this.webJarUrlProvider = webJarUrlProvider;
+    }
+
+    @Override
+    public boolean end(Writer writer, String body) {
+        String logicalPath = findString(path);
+        Optional<String> url = (logicalPath == null)
+            ? Optional.empty()
+            : webJarUrlProvider.resolveUrl(logicalPath, request);
+
+        if (url.isPresent()) {
+            if (StringUtils.isNotBlank(getVar())) {
+                putInContext(url.get());
+            } else {
+                try {
+                    writer.write(url.get());
+                } catch (IOException e) {
+                    LOG.info("Could not write WebJar URL for path '{}'", path, 
e);
+                }
+            }
+        }
+        return super.end(writer, body);
+    }
+
+    @StrutsTagAttribute(required = true,
+        description = "The version-less WebJar resource path, e.g. 
bootstrap/css/bootstrap.min.css")
+    public void setPath(String path) {
+        this.path = path;
+    }
+}
+```
+
+- [ ] **Step 4: Write the JSP tag**
+
+Create `WebJarTag.java`:
+
+```java
+/*
+ * 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.struts2.views.jsp;
+
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.apache.struts2.components.Component;
+import org.apache.struts2.components.WebJar;
+import org.apache.struts2.util.ValueStack;
+
+public class WebJarTag extends ContextBeanTag {
+
+    private static final long serialVersionUID = 1L;
+
+    protected String path;
+
+    @Override
+    public Component getBean(ValueStack stack, HttpServletRequest req, 
HttpServletResponse res) {
+        return new WebJar(stack, req);
+    }
+
+    @Override
+    protected void populateParams() {
+        super.populateParams();
+        WebJar webJar = (WebJar) component;
+        webJar.setPath(path);
+    }
+
+    public void setPath(String path) {
+        this.path = path;
+    }
+}
+```
+
+- [ ] **Step 5: Write the FreeMarker model**
+
+Create `WebJarModel.java`:
+
+```java
+/*
+ * 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.struts2.views.freemarker.tags;
+
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.apache.struts2.components.Component;
+import org.apache.struts2.components.WebJar;
+import org.apache.struts2.util.ValueStack;
+
+public class WebJarModel extends TagModel {
+
+    public WebJarModel(ValueStack stack, HttpServletRequest req, 
HttpServletResponse res) {
+        super(stack, req, res);
+    }
+
+    @Override
+    protected Component getBean() {
+        return new WebJar(stack, req);
+    }
+}
+```
+
+- [ ] **Step 6: Register the model in `StrutsModels`**
+
+In `StrutsModels.java`, add a field (with the other `protected *Model` fields, 
e.g. near `set`):
+
+```java
+    protected WebJarModel webjar;
+```
+
+And a getter (with the other getters, e.g. near `getSet()`):
+
+```java
+    public WebJarModel getWebjar() {
+        if (webjar == null) {
+            webjar = new WebJarModel(stack, req, res);
+        }
+        return webjar;
+    }
+```
+
+- [ ] **Step 7: Run tests to verify they pass**
+
+Run: `mvn test -DskipAssembly -pl core -Dtest=WebJarTest`
+Expected: PASS.
+
+Run: `mvn -q -pl core compile -DskipAssembly`
+Expected: BUILD SUCCESS (annotation processor regenerates the TLD with the new 
`webjar` tag).
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add core/src/main/java/org/apache/struts2/components/WebJar.java 
core/src/main/java/org/apache/struts2/views/jsp/WebJarTag.java 
core/src/main/java/org/apache/struts2/views/freemarker/tags/WebJarModel.java 
core/src/main/java/org/apache/struts2/views/freemarker/tags/StrutsModels.java 
core/src/test/java/org/apache/struts2/components/WebJarTest.java
+git commit -m "WW-5640 feat: add <s:webjar> tag and <@s.webjar> macro
+
+Co-Authored-By: Claude Opus 4.8 <[email protected]>"
+```
+
+---
+
+### Task 8: Full-module verification
+
+**Files:** none (verification only).
+
+**Interfaces:** none.
+
+- [ ] **Step 1: Run the full `core` test suite**
+
+Run: `mvn test -DskipAssembly -pl core`
+Expected: BUILD SUCCESS, no failures. If any pre-existing unrelated failures 
appear, confirm they also fail on a clean `main` checkout before attributing 
them here.
+
+- [ ] **Step 2: Confirm the generated TLD contains the tag**
+
+Run: `grep -c "<name>webjar</name>" 
core/target/classes/META-INF/struts-tags.tld`
+Expected: `1` (the annotation processor emitted the `<s:webjar>` tag entry).
+
+- [ ] **Step 3: Re-confirm dependency footprint**
+
+Run: `mvn -q -pl core dependency:tree -Dincludes=com.fasterxml.jackson.core:*`
+Expected: no `webjars`-sourced Jackson (Jackson may appear from other 
modules/plugins, but must not be a child of `webjars-locator-lite`).
+
+- [ ] **Step 4: Commit (only if verification produced fixes)**
+
+If steps surfaced fixes, commit them:
+
+```bash
+git add -A
+git commit -m "WW-5640 test: full-module verification for webjars support
+
+Co-Authored-By: Claude Opus 4.8 <[email protected]>"
+```
+
+---
+
+## Self-Review
+
+**Spec coverage:**
+- R1 (resolution + cache + fail-closed): Task 3 (`DefaultWebJarUrlProvider`, 
locator caching, empty-on-miss). ✓
+- R2 (serving, reuse content-type/caching, hook point): Task 6 (`/webjars/` 
branch reuses `process()`) + Task 5 (content-type). ✓
+- R3 (`WebJarUrlProvider` + tag + macro, URL string, optional var): Task 3 
(interface) + Task 4 (container) + Task 7 (tag/macro/var). ✓
+- R4 (`struts.webjars.enabled`, `struts.webjars.allowlist`, reuse existing): 
Task 2. ✓
+- R5 (normalize/reject traversal, locator-backed only, allowlist, disabled): 
Task 3 (`split` normalization + allowlist + enabled) + tests. ✓
+- R6 (add `webjars-locator-lite`, verify footprint): Task 1. ✓
+- Testing section (unit resolution, integration serving, tag/macro): Tasks 3, 
5, 6, 7, 8. ✓
+- Decision 6 (branch in loader), 7 (extend MIME), 8 (URL string + var), 9 
(allowlist property): Tasks 6, 5, 7, 2. ✓
+
+**Placeholder scan:** No TBD/TODO. The one "confirm during implementation" 
note (whether `ContextBean.end` dereferences `stack.getContext()`) is a 
defensive test-stub hint with the exact stub given, not a gap. The `Arrays` 
import note in Task 3 is an explicit cleanup instruction. Resolved.
+
+**Type consistency:** `WebJarUrlProvider` methods 
(`resolveResourcePath(String)`, `resolveUrl(String, HttpServletRequest)`, 
`isEnabled()`) are identical across Tasks 3, 6, 7. `DefaultWebJarUrlProvider` 
setters (`setEnabled`, `setAllowlist`, `setStaticContentPath`) match between 
impl (Task 3) and test constructions (Tasks 3, 6). 
`DefaultStaticContentLoader.setWebJarUrlProvider` name matches between Task 6 
impl and Task 6 test. `WebJar(ValueStack, HttpServletRequest)` constructor + 
`setPat [...]
+
+**Risk to re-verify at execution time (per spec):** exact line numbers drift; 
re-locate insertion points by symbol, not line. Confirm 
`WebJarVersionLocator.path`/`fullPath` semantics against the pinned `1.1.3` 
(the impl assumes `path` returns `name/version/file` and `fullPath` returns the 
`META-INF/resources/webjars`-prefixed path).
diff --git a/docs/superpowers/specs/2026-07-01-webjars-support-design.md 
b/docs/superpowers/specs/2026-07-01-webjars-support-design.md
new file mode 100644
index 000000000..233e7db5a
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-01-webjars-support-design.md
@@ -0,0 +1,146 @@
+# Spec: WebJars support in Struts core
+
+**Date:** 2026-07-01
+**JIRA:** [WW-5640](https://issues.apache.org/jira/browse/WW-5640)
+**Status:** Design approved — grounded against 7.2.x source; ready for 
implementation plan
+**Scope:** Framework (`core`) only. The consuming plugin work 
(struts2-bootstrap) is separate, in a separate repo, and starts only after this 
ships in a Struts release.
+
+> ASF process note: commit messages must be prefixed with the ticket (`WW-5640 
...`). Before opening a PR, confirm the change is not a security patch (it is a 
feature) per `SECURITY.md`.
+
+## Goal
+
+Add first-class **WebJars** support to Struts core so client-side libraries 
packaged as WebJars (`org.webjars:*`, shipped under 
`META-INF/resources/webjars/<name>/<version>/…`) can be referenced from 
templates and tags by a **version-less logical path** and served through 
Struts' existing static-content pipeline.
+
+Example: a template references `bootstrap/css/bootstrap.min.css`; Struts 
resolves and serves 
`META-INF/resources/webjars/bootstrap/5.3.8/css/bootstrap.min.css`, and the 
emitted URL is `<ctx>/static/webjars/bootstrap/5.3.8/css/bootstrap.min.css`.
+
+## Motivation
+
+Plugins and applications currently vendor client-side assets directly on the 
classpath and re-download/re-commit them on every upgrade. The 
struts2-bootstrap plugin, for example, commits ~2000 Bootstrap + 
bootstrap-icons files and re-vendors them by hand each release. WebJars replace 
this with a dependency bump (auto-updatable via Renovate/Dependabot). Struts 
should let plugins reference WebJar assets by a stable, version-less path and 
handle resolution + serving centrally.
+
+**First consumer:** the struts2-bootstrap plugin (separate repo, separate 
work).
+
+## Fixed decisions
+
+| # | Decision | Choice | Rationale |
+|---|----------|--------|-----------|
+| Q0 | Serving model | **Struts serves** webjar assets through its 
static-content pipeline (`DefaultStaticContentLoader`), reusing content-type / 
caching handling. | Framework owns caching + security; works even where the 
servlet container does not auto-serve `META-INF/resources/`. Consistent with 
how Struts serves its bundled assets. |
+| 1 | Version resolution | **`org.webjars:webjars-locator-lite`** maps 
version-less path → versioned classpath resource. | Purpose-built for 
frameworks; MIT; single Apache-2.0 transitive dep; no Jackson/scanner; adopted 
by Spring Framework 6.2 for the same reasons. |
+| 2 | Public contract | **`WebJarUrlProvider` interface** 
(container-resolvable) **+ thin `<s:webjar>` tag and `<@s.webjar>` macro** on 
top. | Clean injectable Java seam for plugins + template ergonomics. |
+| 3 | URL prefix | Serve under the **existing static content path**: 
`<staticContentPath>/webjars/…`. | Reuses the already-wired static dispatcher; 
no new servlet mapping; single enable/disable switch. |
+| 4 | Security / allowlist | Hard-constrain resolution to the 
**`META-INF/resources/webjars/` root** with path normalization (block `..`). 
**Optional allowlist** of webjar names via a Struts config property; default = 
all webjars on classpath. | Struts streams classpath bytes, so traversal 
protection is mandatory; allowlist is opt-in defense-in-depth. |
+| 5 | Cache-busting | No query-param cache-buster; the resolved **version 
lives in the URL path**. | Versioned URLs are inherently cache-stable; no ETag 
needed beyond current behavior. |
+| 6 | Serving hook | **Add a `/webjars/` branch inside 
`DefaultStaticContentLoader`**, delegating resolution to `WebJarUrlProvider`, 
then reusing existing `process()`/caching. | Smallest change; single injected 
loader bean; no new multi-loader dispatch wiring. |
+| 7 | MIME coverage | **Extend `getContentType()`** with common webjar asset 
types (fonts, svg, source maps, json, ico). | Current map returns `null` for 
`.woff2/.ttf/.svg/.map/.eot/.ico/.json`, breaking web-font loading. Benefits 
existing static content too. |
+| 8 | Tag output | `<s:webjar>` / `<@s.webjar>` **emit the resolved URL 
string** (with optional `var` to store in the value stack). | Composes with the 
existing `<s:script>`/`<s:link>` tags; caller controls the element. |
+| 9 | Allowlist config | Struts config property **`struts.webjars.allowlist`** 
(comma-separated; empty = all). | Consistent with `struts.webjars.enabled` and 
the rest of the `struts.*` config surface. |
+| 10 | Target release | Land in the **next Struts 7.x minor**. | — |
+
+## Architecture
+
+One resolution seam, consumed by two callers (serving + URL-building):
+
+```
+                    ┌─────────────────────────────┐
+   <s:webjar> ─────▶│                             │
+   <@s.webjar> ────▶│      WebJarUrlProvider       │───▶ WebJarVersionLocator
+                    │   (DefaultWebJarUrlProvider) │      
(webjars-locator-lite,
+   DefaultStatic ──▶│                             │       singleton, cached)
+   ContentLoader    └─────────────────────────────┘
+   (/webjars/ branch)
+```
+
+### Grounding (verified against 7.2.x source)
+
+- **Dispatch chain:** `StrutsPrepareAndExecuteFilter.tryHandleRequest` → 
`ExecuteOperations.executeStaticResourceRequest` → 
`StaticContentLoader.canHandle(path)` → `findStaticResource(path, req, res)`.
+- **Loader bean:** single container bean in 
`core/src/main/resources/struts-beans.xml`:
+  `<bean type="org.apache.struts2.dispatcher.StaticContentLoader" 
class="org.apache.struts2.dispatcher.DefaultStaticContentLoader" 
name="struts"/>`.
+- **`canHandle`:** `serveStatic && resourcePath.startsWith(uiStaticContentPath 
+ "/")` (`DefaultStaticContentLoader`).
+- **Fixed serving roots today:** `getAdditionalPackages()` → 
`org.apache.struts2.static`, `template`, `static` (+ debugging in devMode). 
Webjars need a distinct branch because resolution injects a **version**, so it 
is not just another package prefix.
+- **Caching:** `process()` sets caching headers gated on 
`serveStaticBrowserCache` (`struts.serve.static.browserCache`); uses a fixed 
`Last-Modified`, no ETag. Versioned URLs make this sufficient.
+- **Config pattern:** `StrutsConstants` (`public static final String 
struts.*`) + `core/src/main/resources/org/apache/struts2/default.properties` 
defaults + `@Inject(StrutsConstants.XXX)` setters.
+- **DI pattern:** interface + default impl registered in `struts-beans.xml` as 
`name="struts"`; obtained via `Container.getInstance(Type.class)` or `@Inject`.
+- **Tag pattern:** `Component` (or `ContextBean`) subclass + JSP `*Tag` 
(`getBean`/`populateParams`) + FreeMarker `*Model extends TagModel` (`getBean`) 
+ registration in `StrutsModels` (field + getter). `<s:url>` builds 
context-aware URLs via `DefaultUrlHelper.buildUrl` / 
`request.getContextPath()`. `<s:script>`/`<s:link>` already render full 
elements pointing at static content.
+- **Locator API:** `org.webjars.WebJarVersionLocator` (webjars-locator-lite 
1.1.3, MIT; one transitive dep `org.jspecify:jspecify:1.0.0`, Apache-2.0). 
`WEBJARS_PATH_PREFIX = "META-INF/resources/webjars"`. `fullPath(name, 
filePath)` → versioned classpath path or **`null`** when unresolved. 
Thread-safe; recommended as a singleton.
+
+> Class/method names above are verified against the current branch but must be 
re-confirmed at implementation time (the tree moves).
+
+## Components
+
+### `WebJarUrlProvider` (public contract — R3)
+Public, stable interface in an appropriate `org.apache.struts2` package (e.g. 
`org.apache.struts2.views.webjars` — final package TBD during implementation, 
following neighbours). Container-registered so plugins depend on the interface, 
not internals.
+
+Responsibilities:
+- **Resolve to classpath resource** (for serving): logical `<webjar>/<path>` → 
`META-INF/resources/webjars/<webjar>/<version>/<path>`, or empty/absent when 
unresolved or blocked.
+- **Resolve to servable URL** (for tags): `<contextPath> + <staticContentPath> 
+ "/webjars/" + <webjar>/<version>/<path>`.
+- Apply the **allowlist** and the **enabled** switch.
+- Enforce **path normalization** and reject anything escaping 
`META-INF/resources/webjars/`.
+
+`DefaultWebJarUrlProvider`:
+- Holds a singleton `WebJarVersionLocator` (constructed once; thread-safe).
+- `@Inject`s `struts.webjars.enabled`, `struts.webjars.allowlist`, 
`struts.ui.staticContentPath`.
+- Fail-closed: locator `null` → no resource / no URL emitted.
+
+### Serving branch in `DefaultStaticContentLoader` (R2, Decision 6)
+- `canHandle` already matches `<staticContentPath>/...`; add recognition of 
the `/webjars/` sub-prefix and gate it additionally on `struts.webjars.enabled`.
+- In `findStaticResource`, when the cleaned path starts with `/webjars/`, 
strip the prefix, hand the logical `<webjar>/<path>` to `WebJarUrlProvider` for 
classpath resolution, obtain the resource URL, and stream it through the 
existing `process()` (reusing caching + the extended content-type map). 
Unresolved → **404** (fail closed); never fall through to arbitrary classpath 
serving.
+
+### Content-type extension (Decision 7)
+Extend `getContentType()` to cover at least: `.woff`, `.woff2`, `.ttf`, 
`.eot`, `.otf`, `.svg`, `.map` (→ `application/json`), `.json`, `.ico`, `.mjs` 
(→ `text/javascript`). Applies to all static content, not just webjars.
+
+### `<s:webjar>` tag + `<@s.webjar>` macro (R3, Decision 8)
+- `WebJar` component (extends `ContextBean` to inherit `var` support) with a 
`path` attribute; `start`/`end` resolves via `WebJarUrlProvider` and emits the 
URL string, or stores it in `var` when set.
+- JSP `WebJarTag` (`getBean` + `populateParams`); FreeMarker `WebJarModel 
extends TagModel`; register field + getter in `StrutsModels`. TLD entry is 
generated from `@StrutsTag`/`@StrutsTagAttribute` annotations.
+- Unresolved path → emit nothing (fail closed).
+
+### Configuration (R4, Decision 9)
+New `StrutsConstants` + `default.properties`:
+- `struts.webjars.enabled=true` — master switch.
+- `struts.webjars.allowlist=` — optional comma-separated webjar names; empty = 
all.
+
+Reuse existing `struts.serve.static`, `struts.serve.static.browserCache`, 
`struts.ui.staticContentPath`. No new prefix constant.
+
+### Dependency (R6)
+Add `org.webjars:webjars-locator-lite` (1.1.x, currently 1.1.3) to `core`. 
Manage the version in `bom`/`parent` per project convention. Transitive 
footprint: `org.jspecify:jspecify` only (Apache-2.0). Both Category A — no ASF 
licensing issue.
+
+## Security (R5)
+
+- Normalize the requested path and **reject any resolved path escaping 
`META-INF/resources/webjars/`** (block `..` traversal and encoded variants; the 
loader already `URLDecoder.decode`s in `buildPath`, so normalize post-decode).
+- Only serve resources whose resolved path comes from the locator (a known 
webjar); never raw classpath lookups.
+- Honour the optional allowlist.
+- Honour `struts.webjars.enabled=false` (both serving and URL emission become 
inert).
+
+## Data flow
+
+**Serving:** request 
`<ctx>/static/webjars/bootstrap/5.3.8/css/bootstrap.min.css` → filter → 
`canHandle` (static + `/webjars/` + enabled) → `findStaticResource` strips 
`/static`, sees `/webjars/…`, asks `WebJarUrlProvider` to resolve to 
`META-INF/resources/webjars/bootstrap/5.3.8/css/bootstrap.min.css`, validates 
containment, `findResource`, `process()` → 200 + content-type + caching 
headers. Unresolved/blocked → 404.
+
+**URL building:** `<s:webjar path="bootstrap/css/bootstrap.min.css"/>` → 
component → `WebJarUrlProvider` resolves version and composes 
`<ctx>/static/webjars/bootstrap/5.3.8/css/bootstrap.min.css` → emitted string 
(or stored in `var`). Typically wrapped by the caller: `<s:link 
href="%{webjarUrl}"/>` or `<link rel="stylesheet" href="...">`.
+
+## Error handling
+
+- **Unresolved webjar/path:** serving → 404; URL building → empty output / 
absent `var`. Never throw to the user; log at debug.
+- **Disabled:** `canHandle` returns false for `/webjars/`; tag emits nothing.
+- **Traversal / out-of-root:** treated as unresolved → 404 / empty.
+- **Locator construction failure:** provider degrades to fail-closed; logged.
+
+## Testing
+
+- **Unit — resolution (`DefaultWebJarUrlProvider`):** known path → expected 
versioned URL; unknown webjar/path → empty; traversal 
(`bootstrap/../../../etc/passwd`, encoded `..`) → rejected; allowlist blocks a 
non-listed webjar; `enabled=false` → inert.
+- **Unit — content-type:** each new extension maps to the expected MIME type.
+- **Integration — serving:** request a served webjar asset → 200 + correct 
content-type + caching headers governed by `struts.serve.static.browserCache`; 
`struts.webjars.enabled=false` → 404/not served; unknown asset → 404.
+- **Tag/macro:** `<s:webjar>` and `<@s.webjar>` render the resolved URL; `var` 
stores it; unresolved → empty.
+
+Use JUnit 5 + AssertJ + Mockito, per project convention. A test webjar (e.g. a 
small `org.webjars` artifact) is added as a `test` dependency to `core` for 
integration coverage.
+
+## Out of scope
+
+- Container-served model (relying on Servlet `META-INF/resources` 
auto-serving) — rejected in favour of Q0.
+- ETag support / per-resource `Last-Modified` — versioned URLs make it 
unnecessary.
+- The consuming plugin's changes (separate work, separate repo).
+- Any `<s:script>`/`<s:link>` convenience overload that auto-emits an element 
from a webjar path — possible follow-up; this spec emits URL strings only.
+
+## Confirm during implementation
+
+- Final package for `WebJarUrlProvider` and the tag classes (follow existing 
neighbours).
+- Exact `canHandle`/`findStaticResource` edit points against the then-current 
`DefaultStaticContentLoader`.
+- `webjars-locator-lite` version pinned via `bom`/`parent`; re-check latest 
1.1.x at implementation time.
+- Whether `struts.webjars.allowlist` matches on webjar name only (assumed) vs. 
name+path prefix.
diff --git a/parent/pom.xml b/parent/pom.xml
index 3708c577c..e51a067bb 100644
--- a/parent/pom.xml
+++ b/parent/pom.xml
@@ -54,6 +54,18 @@
                 <version>3.2.4</version>
             </dependency>
 
+            <dependency>
+                <groupId>org.webjars</groupId>
+                <artifactId>webjars-locator-lite</artifactId>
+                <version>${webjars-locator-lite.version}</version>
+            </dependency>
+
+            <dependency>
+                <groupId>org.webjars</groupId>
+                <artifactId>jquery</artifactId>
+                <version>${webjars-jquery.version}</version>
+            </dependency>
+
             <!-- Velocity -->
             <dependency>
                 <groupId>org.apache.velocity</groupId>
diff --git a/pom.xml b/pom.xml
index 66ff6a0d3..502c60652 100644
--- a/pom.xml
+++ b/pom.xml
@@ -129,6 +129,8 @@
         <spring.version>6.2.12</spring.version>
         <struts-annotations.version>2.0</struts-annotations.version>
         <velocity-tools.version>3.1</velocity-tools.version>
+        <webjars-jquery.version>3.7.1</webjars-jquery.version>
+        <webjars-locator-lite.version>1.1.3</webjars-locator-lite.version>
         <weld.version>6.0.4.Final</weld.version>
 
         <!-- Site generation -->

Reply via email to