From 68760b5a064672aece30d601896359e1e3d4b03f Mon Sep 17 00:00:00 2001 From: Marc Sladek Date: Wed, 8 Jul 2026 14:41:53 +0200 Subject: [PATCH 1/2] FrontendResourceResolver CSS support --- .../com/celements/css/CssScriptService.java | 32 ++- .../FrontendResourceIncludeCssListener.java | 33 +++ .../javascript/FrontendResourceResolver.java | 58 ++++- .../celements/javascript/JSScriptService.java | 5 +- .../java/com/celements/web/css/CSSEngine.java | 80 ++++--- .../web/css/CSSFrontendResource.java | 65 +++++ .../java/com/celements/web/css/CSSString.java | 9 +- .../web/plugin/api/CelementsWebPluginApi.java | 8 +- .../web/plugin/cmd/AttachmentURLCommand.java | 18 +- .../celements/web/plugin/cmd/CssCommand.java | 57 +++-- .../cmd/ExternalJavaScriptFilesCommand.java | 223 ++++++++---------- ...rontendResourceIncludeCssListenerTest.java | 65 +++++ .../FrontendResourceResolverTest.java | 92 ++++++++ .../com/celements/web/css/CSSEngineTest.java | 74 +++++- .../plugin/cmd/AttachmentURLCommandTest.java | 7 +- .../web/plugin/cmd/CssCommandTest.java | 11 +- .../ExternalJavaScriptFilesCommandTest.java | 214 ++++++++++++----- 17 files changed, 767 insertions(+), 284 deletions(-) create mode 100644 src/main/java/com/celements/javascript/FrontendResourceIncludeCssListener.java create mode 100644 src/main/java/com/celements/web/css/CSSFrontendResource.java create mode 100644 src/test/java/com/celements/javascript/FrontendResourceIncludeCssListenerTest.java create mode 100644 src/test/java/com/celements/javascript/FrontendResourceResolverTest.java diff --git a/src/main/java/com/celements/css/CssScriptService.java b/src/main/java/com/celements/css/CssScriptService.java index 79ec1057c..109c413e6 100644 --- a/src/main/java/com/celements/css/CssScriptService.java +++ b/src/main/java/com/celements/css/CssScriptService.java @@ -2,13 +2,15 @@ import java.util.List; +import javax.inject.Inject; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.xwiki.component.annotation.Component; -import org.xwiki.component.annotation.Requirement; import org.xwiki.context.Execution; import org.xwiki.script.service.ScriptService; +import com.celements.execution.XWikiExecutionProp; import com.celements.web.css.CSS; import com.celements.web.plugin.cmd.CssCommand; import com.xpn.xwiki.XWikiContext; @@ -17,20 +19,21 @@ @Component("css") public class CssScriptService implements ScriptService { - public static final String CELEMENTS_CSSCOMMAND = "com.celements.web.CssCommand"; - public static final Logger LOGGER = LoggerFactory.getLogger(CssScriptService.class); - @Requirement + @Inject private Execution execution; + @Inject + private CssCommand cssCommand; + private XWikiContext getContext() { - return (XWikiContext) execution.getContext().getProperty("xwikicontext"); + return execution.getContext().get(XWikiExecutionProp.XWIKI_CONTEXT).orElseThrow(); } public List getAllCSS() { try { - return getCssCmd().getAllCSS(getContext()); + return cssCommand.getAllCSS(getContext()); } catch (XWikiException e) { LOGGER.error("Call to CssComman.getAllCss failed.", e); return List.of(); @@ -39,7 +42,7 @@ public List getAllCSS() { public String displayAllCSS() { try { - return getCssCmd().displayAllCSS(getContext()); + return cssCommand.displayAllCSS(getContext()); } catch (XWikiException e) { LOGGER.error("Call to CssCommand.displayAllCss failed.", e); return ""; @@ -48,7 +51,7 @@ public String displayAllCSS() { public List getRTEContentCSS() { try { - return getCssCmd().getRTEContentCSS(getContext()); + return cssCommand.getRTEContentCSS(getContext()); } catch (XWikiException e) { LOGGER.error("Call to CssCommand.getRTEContentCSS failed.", e); return List.of(); @@ -56,12 +59,12 @@ public List getRTEContentCSS() { } public void includeCSSPage(String css) { - getCssCmd().includeCSSPage(css, getContext()); + cssCommand.includeCSSPage(css, getContext()); } public void includeCSSAfterPreferences(String css) { try { - getCssCmd().includeCSSAfterPreferences(css, getContext()); + cssCommand.includeCSSAfterPreferences(css, getContext()); } catch (XWikiException e) { LOGGER.error("Call to CssCommand.includeCSSAfterPreferences failed.", e); } @@ -75,13 +78,6 @@ public void includeCSSAfterPreferences(String css) { */ @Deprecated(since = "6.7", forRemoval = true) public void includeCSSAfterSkin(String css) { - getCssCmd().includeCSSAfterSkin(css, getContext()); - } - - private CssCommand getCssCmd() { - if (!getContext().containsKey(CELEMENTS_CSSCOMMAND)) { - getContext().put(CELEMENTS_CSSCOMMAND, new CssCommand()); - } - return (CssCommand) getContext().get(CELEMENTS_CSSCOMMAND); + cssCommand.includeCSSAfterSkin(css, getContext()); } } diff --git a/src/main/java/com/celements/javascript/FrontendResourceIncludeCssListener.java b/src/main/java/com/celements/javascript/FrontendResourceIncludeCssListener.java new file mode 100644 index 000000000..60a344784 --- /dev/null +++ b/src/main/java/com/celements/javascript/FrontendResourceIncludeCssListener.java @@ -0,0 +1,33 @@ +package com.celements.javascript; + +import javax.inject.Inject; + +import org.springframework.stereotype.Component; + +import com.celements.web.plugin.cmd.CssCommand; +import com.celements.web.plugin.cmd.ExternalJavaScriptFilesCommand; +import com.celements.web.plugin.cmd.IExtJSFilesListener; + +@Component +public class FrontendResourceIncludeCssListener implements IExtJSFilesListener { + + private final CssCommand cssCommand; + private final FrontendResourceResolver resolver; + + @Inject + public FrontendResourceIncludeCssListener( + CssCommand cssCommand, + FrontendResourceResolver resolver) { + this.cssCommand = cssCommand; + this.resolver = resolver; + } + + @Override + public void beforeAllExtFinish(ExternalJavaScriptFilesCommand jsCommand) { + // Include frontend entry sources. CSSEngine treats :frontend/... values as manifest keys and + // expands them to the CSS assets emitted for that entrypoint. + jsCommand.streamExtJsFiles() + .filter(resolver::isFrontendSource) + .forEach(cssCommand::includeCSSPage); + } +} diff --git a/src/main/java/com/celements/javascript/FrontendResourceResolver.java b/src/main/java/com/celements/javascript/FrontendResourceResolver.java index a3d669683..d379c932e 100644 --- a/src/main/java/com/celements/javascript/FrontendResourceResolver.java +++ b/src/main/java/com/celements/javascript/FrontendResourceResolver.java @@ -1,7 +1,10 @@ package com.celements.javascript; +import static java.util.Objects.*; + import java.io.IOException; import java.io.InputStream; +import java.util.List; import java.util.Map; import java.util.Map.Entry; import java.util.Optional; @@ -24,7 +27,7 @@ import one.util.streamex.StreamEx; /** - * Resolves frontend resource paths based on the vite manifest file. + * Resolves frontend JavaScript & CSS resources based on the vite manifest file. * * The manifest file is generated by vite during the build process and contains mappings from source * files to their corresponding production files with hashed names. @@ -44,8 +47,9 @@ public class FrontendResourceResolver { private final ResourcePatternResolver resourceLoader; private final ObjectMapper objectMapper; - // ":frontend/file.ts" -> "dist/file.a8b3.mjs" - private final Supplier> manifest = Suppliers.memoize(this::readManifestFiles); + // ":frontend/file.ts" -> ("dist/file.a8b3.mjs", ["dist/assets/file.a8b3.css"]) + private final Supplier> manifest = Suppliers.memoize( + this::readManifestFiles); @Inject public FrontendResourceResolver(ResourcePatternResolver resourceLoader) { @@ -58,17 +62,24 @@ public void init() { manifest.get(); } + public boolean isFrontendSource(String path) { + return requireNonNullElse(path, "").trim().startsWith(SOURCE_PREFIX); + } + /** - * Resolves a source path to a production dist path. - * @param sourcePath e.g. ":frontend/file.ts" + * Resolves a frontend source path to its emitted dist files. * - * @return The resolved path e.g. "dist/file.a8b3.mjs" + * @param sourcePath + * e.g. ":frontend/file.ts" + * + * @return The resolved frontend resource with JS and CSS dist paths, e.g. + * "dist/file.a8b3.mjs" and ["dist/assets/file.a8b3.css"] */ - public Optional resolve(String sourcePath) { + public Optional get(String sourcePath) { return Optional.ofNullable(manifest.get().get(sourcePath)); } - private Map readManifestFiles() { + private Map readManifestFiles() { Resource[] resources; try { resources = resourceLoader.getResources(MANIFEST_PATH); @@ -81,7 +92,7 @@ private Map readManifestFiles() { .flatMap(rootNode -> StreamEx.of(rootNode.fields())) .mapToEntry(Entry::getKey, Entry::getValue) .flatMapKeys(this::prefixKey) - .flatMapValues(this::extractFilePath) + .flatMapValues(this::extractResource) .toImmutableMap(); // ISE on duplicate keys, we expect unique source files LOGGER.info("readManifest - {}", map); return map; @@ -102,11 +113,34 @@ private Stream prefixKey(String key) { .map(k -> k.replaceFirst(MANIFEST_KEY_PREFIX, SOURCE_PREFIX)); } - private Stream extractFilePath(JsonNode node) { - return Stream.ofNullable(node) + private Stream extractResource(JsonNode node) { + return extractFilePath(node) + .map(jsPath -> new FrontendResource(jsPath, extractCssPaths(node))) + .stream(); + } + + private Optional extractFilePath(JsonNode node) { + return Optional.ofNullable(node) .map(n -> n.get("file")) + .map(this::toTargetPath); + } + + private List extractCssPaths(JsonNode node) { + return Optional.ofNullable(node) + .map(n -> n.get("css")) + .filter(JsonNode::isArray) + .stream().flatMap(css -> StreamEx.of(css.elements())) + .map(this::toTargetPath) + .toList(); + } + + private String toTargetPath(JsonNode node) { + return Optional.ofNullable(node) .map(f -> f.asText()) .filter(f -> !f.isEmpty()) - .map(f -> TARGET_DIR + f); + .map(f -> TARGET_DIR + f) + .orElse(null); } + + public record FrontendResource(String jsPath, List cssPaths) {} } diff --git a/src/main/java/com/celements/javascript/JSScriptService.java b/src/main/java/com/celements/javascript/JSScriptService.java index 213dbe76c..2a8486b3b 100644 --- a/src/main/java/com/celements/javascript/JSScriptService.java +++ b/src/main/java/com/celements/javascript/JSScriptService.java @@ -1,5 +1,7 @@ package com.celements.javascript; +import static com.celements.spring.context.SpringContextProvider.*; + import java.util.List; import java.util.stream.Collectors; @@ -166,7 +168,8 @@ public String addLazyExtJSfile(@Nullable String jsFile, @Nullable String action, private ExternalJavaScriptFilesCommand getExtJavaScriptFileCmd() { if (getContext().get(JAVA_SCRIPT_FILES_COMMAND_KEY) == null) { - getContext().put(JAVA_SCRIPT_FILES_COMMAND_KEY, new ExternalJavaScriptFilesCommand()); + getContext().put(JAVA_SCRIPT_FILES_COMMAND_KEY, getSpringContext() + .getBean(ExternalJavaScriptFilesCommand.class)); } return (ExternalJavaScriptFilesCommand) getContext().get(JAVA_SCRIPT_FILES_COMMAND_KEY); } diff --git a/src/main/java/com/celements/web/css/CSSEngine.java b/src/main/java/com/celements/web/css/CSSEngine.java index 6c670bcae..6694a3d53 100644 --- a/src/main/java/com/celements/web/css/CSSEngine.java +++ b/src/main/java/com/celements/web/css/CSSEngine.java @@ -19,31 +19,36 @@ */ package com.celements.web.css; -import java.util.ArrayList; +import static java.util.Objects.*; +import static java.util.stream.Collectors.*; + import java.util.Collections; import java.util.List; +import java.util.Objects; +import java.util.stream.Stream; + +import javax.inject.Inject; import org.apache.velocity.VelocityContext; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; +import com.celements.javascript.FrontendResourceResolver; +import com.google.common.base.Splitter; import com.xpn.xwiki.XWikiContext; import com.xpn.xwiki.objects.BaseObject; +@Component public class CSSEngine implements ICSSEngine { - private static final String _CSS_ENGINE_OBJECT_KEY = "com.celements.web.css.CSSEngine"; static final Logger LOGGER = LoggerFactory.getLogger(CSSEngine.class); - private CSSEngine() { - } + private final FrontendResourceResolver resolver; - public static ICSSEngine getCSSEngine(XWikiContext context) { - Object storeObj = context.get(_CSS_ENGINE_OBJECT_KEY); - if ((storeObj == null) || !(storeObj instanceof ICSSEngine)) { - context.put(_CSS_ENGINE_OBJECT_KEY, new CSSEngine()); - } - return (ICSSEngine) context.get(_CSS_ENGINE_OBJECT_KEY); + @Inject + public CSSEngine(FrontendResourceResolver resolver) { + this.resolver = resolver; } /** @@ -60,38 +65,35 @@ public static ICSSEngine getCSSEngine(XWikiContext context) { @SuppressWarnings("unchecked") public List includeCSS(String css, String field, List baseCSSList, XWikiContext context) { - LOGGER.debug("adding '" + css + "' to " + field + ". List contains already " - + ((baseCSSList != null) ? baseCSSList.size() : "0") + " items."); + LOGGER.debug("includeCSS: adding '{}' to {}. List contains already {} items.", css, field, + ((baseCSSList != null) ? baseCSSList.size() : "0")); VelocityContext vcontext = ((VelocityContext) context.get("vcontext")); - List cssList = Collections.emptyList(); - - if (vcontext != null) { - if (vcontext.containsKey(field)) { - cssList = (List) vcontext.get(field); - } else { - cssList = new ArrayList<>(); - if (baseCSSList != null) { - for (BaseObject cssObj : baseCSSList) { - if (cssObj != null) { - LOGGER.debug("includeCSS: adding baseObject [" + cssObj.getStringValue("cssname") - + "]."); - cssList.add(new CSSBaseObject(cssObj)); - } - } - } - } - - String[] newCSSList = css.split(" "); - for (int i = 0; i < newCSSList.length; i++) { - if ((newCSSList[i] != null) && (!newCSSList[i].trim().equals(""))) { - cssList.add(new CSSString(newCSSList[i], context)); - } - } - - vcontext.put(field, cssList); + List cssList; + if (vcontext == null) { + return Collections.emptyList(); + } else if (vcontext.containsKey(field)) { + cssList = (List) vcontext.get(field); + } else { + cssList = Stream.ofNullable(baseCSSList).flatMap(List::stream) + .filter(Objects::nonNull) + .map(CSSBaseObject::new) + .collect(toList()); } - + Splitter.on(" ").trimResults().omitEmptyStrings() + .splitToStream(requireNonNullElse(css, "")) + .flatMap(this::collectCssPaths) + .forEach(cssList::add); + vcontext.put(field, cssList); return cssList; } + private Stream collectCssPaths(String path) { + if (resolver.isFrontendSource(path)) { + return resolver.get(path).stream() + .flatMap(resource -> resource.cssPaths().stream()) + .map(CSSFrontendResource::new); + } else { + return Stream.of(new CSSString(path)); + } + } } diff --git a/src/main/java/com/celements/web/css/CSSFrontendResource.java b/src/main/java/com/celements/web/css/CSSFrontendResource.java new file mode 100644 index 000000000..6612e5bae --- /dev/null +++ b/src/main/java/com/celements/web/css/CSSFrontendResource.java @@ -0,0 +1,65 @@ +package com.celements.web.css; + +import java.util.Objects; + +import com.xpn.xwiki.XWikiContext; +import com.xpn.xwiki.api.Attachment; + +final class CSSFrontendResource extends CSS { + + private final String file; + + CSSFrontendResource(String file) { + this.file = file; + } + + @Override + public String getCSS(XWikiContext context) { + return getAttachmentURLcmd().getDiskFileUrl(file); + } + + @Override + public boolean isAlternate() { + return false; // vite-emitted entry CSS is a normal stylesheet + } + + @Override + public String getTitle() { + return ""; + } + + @Override + public String getMedia() { + return "all"; + } + + @Override + public boolean isContentCSS() { + return false; // Frontend bundle CSS must not be reused as RTE editor content CSS. + } + + @Override + public boolean isAttachment() { + return false; + } + + @Override + public Attachment getAttachment() { + return null; + } + + @Override + public String getCssBasePath() { + return file; + } + + @Override + public int hashCode() { + return Objects.hash(file); + } + + @Override + public boolean equals(Object obj) { + return (obj instanceof CSSFrontendResource other) && Objects.equals(this.file, other.file); + } +} diff --git a/src/main/java/com/celements/web/css/CSSString.java b/src/main/java/com/celements/web/css/CSSString.java index 5e5626692..323ee064a 100644 --- a/src/main/java/com/celements/web/css/CSSString.java +++ b/src/main/java/com/celements/web/css/CSSString.java @@ -47,12 +47,17 @@ public class CSSString extends CSS { private String media; private boolean isContentCSS; - public CSSString(String file, XWikiContext context) { - super(context); + public CSSString(String file) { + super(); boolean contentCSS = (file.endsWith("-content.css") || file.endsWith("_content.css")); initFields(file, false, "", "all", contentCSS); } + @Deprecated + public CSSString(String file, XWikiContext context) { + this(file); + } + public CSSString(String file, String media, XWikiContext context) { super(context); initFields(file, false, "", media, false); diff --git a/src/main/java/com/celements/web/plugin/api/CelementsWebPluginApi.java b/src/main/java/com/celements/web/plugin/api/CelementsWebPluginApi.java index 8faf9c57a..25ec4fbec 100644 --- a/src/main/java/com/celements/web/plugin/api/CelementsWebPluginApi.java +++ b/src/main/java/com/celements/web/plugin/api/CelementsWebPluginApi.java @@ -74,12 +74,6 @@ @Deprecated public class CelementsWebPluginApi extends Api { - /** - * @Deprecated: since 2.59 instead use variable in {@link CssScriptService} - */ - @Deprecated - public static final String CELEMENTS_CSSCOMMAND = CssScriptService.CELEMENTS_CSSCOMMAND; - /** * @Deprecated: since 2.59 instead use variable in {@link JSScriptService} */ @@ -203,6 +197,7 @@ public List getAttachmentListForTagSortedSpace(String spaceName, Str imagesOnly, start, nb); } + @Deprecated public List getAttachmentListSorted(Document doc, String comparator) throws ClassNotFoundException { return getWebUtilsScriptService().getAttachmentListSorted(doc, comparator); @@ -220,6 +215,7 @@ public List getAttachmentListSorted(Document doc, String comparator, nb); } + @Deprecated public List getAttachmentListForTagSorted(Document doc, String tagName, String comparator, boolean imagesOnly, int start, int nb) { return getWebUtilsService().getAttachmentListForTagSorted(doc, tagName, comparator, imagesOnly, diff --git a/src/main/java/com/celements/web/plugin/cmd/AttachmentURLCommand.java b/src/main/java/com/celements/web/plugin/cmd/AttachmentURLCommand.java index 88451a486..843656d44 100644 --- a/src/main/java/com/celements/web/plugin/cmd/AttachmentURLCommand.java +++ b/src/main/java/com/celements/web/plugin/cmd/AttachmentURLCommand.java @@ -21,6 +21,7 @@ import static com.celements.common.MoreOptional.*; import static com.celements.execution.XWikiExecutionProp.*; +import static com.celements.spring.context.SpringContextProvider.*; import static com.google.common.base.Strings.*; import java.net.MalformedURLException; @@ -41,6 +42,7 @@ import com.celements.filebase.IAttachmentServiceRole; import com.celements.init.XWikiProvider; import com.celements.javascript.FrontendResourceResolver; +import com.celements.javascript.FrontendResourceResolver.FrontendResource; import com.celements.model.access.exception.AttachmentNotExistsException; import com.celements.model.access.exception.DocumentNotExistsException; import com.celements.model.util.ModelUtils; @@ -104,10 +106,9 @@ public Optional getAttachmentURL(String link, String action, Stri return Optional.empty(); } } else if (isOnDiskLink(link)) { - var resolved = getFrontendResourceResolver().resolve(link.trim()); + var resolved = getFrontendResourceResolver().get(link.trim()).map(FrontendResource::jsPath); var path = resolved.orElseGet(() -> link.trim().substring(1)); - url = getXWiki().getSkinFile(path, true, getContext()) - .replace("/skin/", "/" + action + "/"); + url = getDiskFileUrl(path, action); if (resolved.isEmpty()) { // frontend resource is already versioned by build process versionProvider = () -> getLastStartupTimeStamp().getFileModificationDate(path); } @@ -164,6 +165,15 @@ public boolean isOnDiskLink(String link) { return isAttachmentLink; } + public String getDiskFileUrl(String path, String action) { + return getXWiki().getSkinFile(path, true, getContext()) + .replace("/skin/", "/" + action + "/"); + } + + public String getDiskFileUrl(String path) { + return getDiskFileUrl(path, "file"); + } + public String getExternalAttachmentURL(String fileName, String action, XWikiContext context) { try { return context.getURLFactory().getServerURL(context).toExternalForm() + getAttachmentURL( @@ -175,7 +185,7 @@ public String getExternalAttachmentURL(String fileName, String action, XWikiCont } private FrontendResourceResolver getFrontendResourceResolver() { - return Utils.getComponent(FrontendResourceResolver.class); + return getSpringContext().getBean(FrontendResourceResolver.class); } private IAttachmentServiceRole getAttachmentService() { diff --git a/src/main/java/com/celements/web/plugin/cmd/CssCommand.java b/src/main/java/com/celements/web/plugin/cmd/CssCommand.java index 26a33dca8..a5f9beb04 100644 --- a/src/main/java/com/celements/web/plugin/cmd/CssCommand.java +++ b/src/main/java/com/celements/web/plugin/cmd/CssCommand.java @@ -19,8 +19,6 @@ */ package com.celements.web.plugin.cmd; -import static com.celements.spring.context.SpringContextProvider.*; - import java.util.ArrayList; import java.util.Collections; import java.util.HashSet; @@ -29,12 +27,17 @@ import java.util.Set; import java.util.Vector; +import javax.inject.Inject; + import org.apache.velocity.VelocityContext; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; +import org.xwiki.context.Execution; import org.xwiki.model.reference.DocumentReference; import com.celements.css.ICssExtensionRole; +import com.celements.execution.XWikiExecutionProp; import com.celements.model.access.IModelAccessFacade; import com.celements.model.access.exception.DocumentNotExistsException; import com.celements.pagelayout.LayoutServiceRole; @@ -45,10 +48,12 @@ import com.xpn.xwiki.XWikiException; import com.xpn.xwiki.doc.XWikiDocument; import com.xpn.xwiki.objects.BaseObject; -import com.xpn.xwiki.web.Utils; +@Component public class CssCommand { + public static final String CELEMENTS_CSSCOMMAND = "com.celements.web.CssCommand"; + public static final String SKINS_USER_CSS_CLASS_SPACE = "Skins"; public static final String SKINS_USER_CSS_CLASS_DOC = "UserCSS"; public static final String SKINS_USER_CSS_CLASS = SKINS_USER_CSS_CLASS_SPACE + "." @@ -56,6 +61,26 @@ public class CssCommand { private static final Logger LOGGER = LoggerFactory.getLogger(CssCommand.class); + private final CSSEngine cssEngine; + private final IModelAccessFacade modelAccess; + private final LayoutServiceRole layoutService; + private final List cssExtensions; + private final Execution execution; + + @Inject + public CssCommand( + CSSEngine cssEngine, + IModelAccessFacade modelAccess, + LayoutServiceRole layoutService, + List cssExtensions, + Execution execution) { + this.cssEngine = cssEngine; + this.modelAccess = modelAccess; + this.layoutService = layoutService; + this.cssExtensions = cssExtensions; + this.execution = execution; + } + public DocumentReference getSkinsUserCssClassRef(String wikiName) { return new DocumentReference(wikiName, SKINS_USER_CSS_CLASS_SPACE, SKINS_USER_CSS_CLASS_DOC); } @@ -120,6 +145,10 @@ public List getRTEContentCSS(XWikiContext context) throws XWikiException { return cssResultList; } + public List includeCSSPage(String css) { + return includeCSSPage(css, getXContext()); + } + public List includeCSSPage(String css, XWikiContext context) { List skins = null; if ((context != null) && (context.getDoc() != null) && !new PageLayoutCommand().layoutExists( @@ -171,10 +200,9 @@ public List includeCSSAfterPreferences(String css, XWikiContext context) List includeApplicationDefaultCSS() { List cssList = Collections.emptyList(); - List cssExtList = Utils.getComponentList(ICssExtensionRole.class); - if (!cssExtList.isEmpty()) { + if (!cssExtensions.isEmpty()) { cssList = new Vector<>(); - for (ICssExtensionRole cssExt : cssExtList) { + for (ICssExtensionRole cssExt : cssExtensions) { LOGGER.debug("includeApplicationDefaultCSS: for '{}'", cssExt.getClass()); cssList.addAll(cssExt.getCssList()); } @@ -213,7 +241,7 @@ public List includeCSSAfterPageType(String css, XWikiContext context) { } public List includeCSSAfterPageLayout(String css, XWikiContext context) { - Optional pageLayoutDocRefOpt = getLayoutService() + Optional pageLayoutDocRefOpt = layoutService .getLayoutPropDocRefForCurrentDoc(); VelocityContext vcontext = ((VelocityContext) context.get("vcontext")); List cssList = Collections.emptyList(); @@ -221,7 +249,7 @@ public List includeCSSAfterPageLayout(String css, XWikiContext context) { if (pageLayoutDocRefOpt.isPresent() && (vcontext != null)) { List baseList = new ArrayList<>(); try { - XWikiDocument pageLayoutDoc = getModelAccess().getDocument(pageLayoutDocRefOpt.get()); + XWikiDocument pageLayoutDoc = modelAccess.getDocument(pageLayoutDocRefOpt.get()); baseList.addAll(addUserSkinCss(pageLayoutDoc)); } catch (DocumentNotExistsException dne) { LOGGER.info("includeCSSAfterPageLayout pageLayoutDoc {} does not exist", @@ -237,7 +265,7 @@ public List includeCSSAfterPageLayout(String css, XWikiContext context) { private List addUserSkinCss(DocumentReference docRef) { if (docRef != null) { try { - return addUserSkinCss(getModelAccess().getDocument(docRef)); + return addUserSkinCss(modelAccess.getDocument(docRef)); } catch (DocumentNotExistsException exp) { LOGGER.info("addUserSkinCss: document does not exist '{}'", docRef); } @@ -272,15 +300,10 @@ private List addUserSkinCss(XWikiDocument docAPI) { */ private List includeCSS(String css, String field, List baseCSSList, XWikiContext context) { - return CSSEngine.getCSSEngine(context).includeCSS(css, field, baseCSSList, context); + return cssEngine.includeCSS(css, field, baseCSSList, context); } - private IModelAccessFacade getModelAccess() { - return Utils.getComponent(IModelAccessFacade.class); + private XWikiContext getXContext() { + return execution.getContext().get(XWikiExecutionProp.XWIKI_CONTEXT).orElseThrow(); } - - private LayoutServiceRole getLayoutService() { - return getSpringContext().getBean(LayoutServiceRole.class); - } - } diff --git a/src/main/java/com/celements/web/plugin/cmd/ExternalJavaScriptFilesCommand.java b/src/main/java/com/celements/web/plugin/cmd/ExternalJavaScriptFilesCommand.java index e89b855cd..83885aa4e 100644 --- a/src/main/java/com/celements/web/plugin/cmd/ExternalJavaScriptFilesCommand.java +++ b/src/main/java/com/celements/web/plugin/cmd/ExternalJavaScriptFilesCommand.java @@ -21,20 +21,21 @@ import static com.celements.javascript.JsLoadMode.*; import static com.google.common.base.Preconditions.*; +import static java.util.stream.Collectors.*; import java.util.Collections; -import java.util.HashSet; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; -import java.util.function.Supplier; import java.util.stream.Collectors; import java.util.stream.Stream; import javax.annotation.Nullable; import javax.annotation.concurrent.NotThreadSafe; +import javax.inject.Inject; +import javax.inject.Named; import javax.validation.constraints.NotEmpty; import javax.validation.constraints.NotNull; @@ -42,83 +43,90 @@ import org.apache.velocity.VelocityContext; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.config.ConfigurableBeanFactory; +import org.springframework.context.annotation.Scope; +import org.springframework.stereotype.Component; import org.springframework.web.util.UriComponents; import org.xwiki.component.manager.ComponentLookupException; -import org.xwiki.model.reference.ClassReference; import org.xwiki.model.reference.DocumentReference; import com.celements.common.reflect.ReflectiveInstanceSupplier; import com.celements.convert.bean.BeanClassDefConverter; import com.celements.convert.bean.XObjectBeanConverter; import com.celements.javascript.ExtJsFileParameter; +import com.celements.javascript.FrontendResourceResolver; import com.celements.javascript.JavaScriptExternalFilesClass; import com.celements.javascript.JsFileEntry; import com.celements.javascript.JsIsRteContent; import com.celements.model.access.IModelAccessFacade; import com.celements.model.access.exception.DocumentNotExistsException; +import com.celements.model.classes.ClassDefinition; import com.celements.model.context.ModelContext; import com.celements.model.object.xwiki.XWikiObjectFetcher; import com.celements.model.reference.RefBuilder; import com.celements.pagelayout.LayoutServiceRole; import com.celements.pagetype.service.IPageTypeResolverRole; import com.celements.pagetype.xobject.XObjectPageTypeUtilsRole; -import com.celements.web.classes.CelementsClassDefinition; import com.celements.web.service.IWebUtilsService; -import com.google.common.base.Suppliers; -import com.xpn.xwiki.XWikiContext; import com.xpn.xwiki.api.Document; import com.xpn.xwiki.objects.BaseObject; -import com.xpn.xwiki.web.Utils; import one.util.streamex.StreamEx; @NotThreadSafe +@Component +@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) public class ExternalJavaScriptFilesCommand { - /** - * @deprecated since 5.4 instead use IOldCoreClassConfig.JAVA_SCRIPTS_EXTERNAL_FILES_CLASS_DOC - */ - @Deprecated - public static final String JAVA_SCRIPT_EXTERNAL_FILES_CLASS_DOC = "ExternalFiles"; - /** - * @deprecated since 4.0 instead use IOldCoreClassConfig.JAVA_SCRIPTS_EXTERNAL_FILES_CLASS_SPACE - */ - @Deprecated - public static final String JAVA_SCRIPT_EXTERNAL_FILES_CLASS_SPACE = "JavaScript"; - /** - * @deprecated since 4.0 instead use IOldCoreClassConfig.JAVA_SCRIPTS_EXTERNAL_FILES_CLASS - */ - @Deprecated - public static final String JAVA_SCRIPT_EXTERNAL_FILES_CLASS = JAVA_SCRIPT_EXTERNAL_FILES_CLASS_SPACE - + "." + JAVA_SCRIPT_EXTERNAL_FILES_CLASS_DOC; - private static final Logger LOGGER = LoggerFactory .getLogger(ExternalJavaScriptFilesCommand.class); - private static final Supplier> JS_FILE_ENTRY_CONVERTER = Suppliers - .memoize(ExternalJavaScriptFilesCommand::jsFileEntryConverter); + + private final LayoutServiceRole layoutService; + private final IPageTypeResolverRole pageTypeResolver; + private final IModelAccessFacade modelAccess; + private final ModelContext modelContext; + private final XObjectPageTypeUtilsRole objectPageTypeUtils; + private final IWebUtilsService webUtilsService; + private final AttachmentURLCommand attUrlCommand; + private final FrontendResourceResolver frontendResolver; + private final CssCommand cssCommand; + private final BeanClassDefConverter jsFileEntryConverter; private final Set extJSfileSet = new LinkedHashSet<>(); - private final Set extJSAttUrlSet = new HashSet<>(); + private final Set extJSAttUrlSet = new LinkedHashSet<>(); private final Set extJSnotFoundSet = new LinkedHashSet<>(); private boolean displayedAll = false; private boolean collectedAll = false; - /** - * @deprecated since 5.4 instead use {@link ExternalJavaScriptFilesCommand()} - */ - @Deprecated - public ExternalJavaScriptFilesCommand(XWikiContext context) {} - - public ExternalJavaScriptFilesCommand() {} - - private static BeanClassDefConverter jsFileEntryConverter() { - @SuppressWarnings("unchecked") - BeanClassDefConverter converter = Utils.getComponent( - BeanClassDefConverter.class, XObjectBeanConverter.NAME); - converter.initialize(Utils.getComponent(CelementsClassDefinition.class, - JavaScriptExternalFilesClass.CLASS_DEF_HINT)); + @Inject + public ExternalJavaScriptFilesCommand( + LayoutServiceRole layoutService, + IPageTypeResolverRole pageTypeResolver, + IModelAccessFacade modelAccess, + ModelContext modelContext, + XObjectPageTypeUtilsRole objectPageTypeUtils, + IWebUtilsService webUtilsService, + AttachmentURLCommand attUrlCommand, + CssCommand cssCommand, + FrontendResourceResolver frontendResolver, + @Named(XObjectBeanConverter.NAME) BeanClassDefConverter converter, + @Named(JavaScriptExternalFilesClass.CLASS_DEF_HINT) ClassDefinition jsExtClassDef) { + this.attUrlCommand = attUrlCommand; + this.frontendResolver = frontendResolver; + this.cssCommand = cssCommand; + this.jsFileEntryConverter = converter; + this.layoutService = layoutService; + this.pageTypeResolver = pageTypeResolver; + this.modelAccess = modelAccess; + this.modelContext = modelContext; + this.objectPageTypeUtils = objectPageTypeUtils; + this.webUtilsService = webUtilsService; + converter.initialize(jsExtClassDef); converter.initialize(new ReflectiveInstanceSupplier<>(JsFileEntry.class)); - return converter; + } + + public Stream streamExtJsFiles() { + return extJSAttUrlSet.stream(); } /** @@ -211,20 +219,13 @@ public String includeExtJsFile(@NotNull ExtJsFileParameter extJsFileParams) { @NotEmpty public String getLazyLoadTag(@NotNull ExtJsFileParameter extJsFileParams) { - return getLazyLoadTag(extJsFileParams, null); - } - - @NotEmpty - String getLazyLoadTag(@NotNull ExtJsFileParameter extJsFileParams, - @Nullable AttachmentURLCommand attUrlCmdMock) { - return ""; } @NotNull - private Optional generateUrl(@NotNull ExtJsFileParameter extJsFileParams, - @Nullable AttachmentURLCommand attUrlCmdMock) { - return getAttUrlCmd(attUrlCmdMock).getAttachmentURL( + private Optional generateUrl(@NotNull ExtJsFileParameter extJsFileParams) { + return attUrlCommand.getAttachmentURL( extJsFileParams.getJsFile(), extJsFileParams.getAction().orElse(null), extJsFileParams.getQueryString().orElse(null)) @@ -233,20 +234,13 @@ private Optional generateUrl(@NotNull ExtJsFileParameter extJsFileParams @NotNull public String addExtJSfileOnce(@NotNull ExtJsFileParameter extJsFileParams) { - return addExtJSfileOnce(extJsFileParams, null); - } - - @NotNull - String addExtJSfileOnce(@NotNull ExtJsFileParameter extJsFileParams, - @Nullable AttachmentURLCommand attUrlCmdMock) { if (!extJSAttUrlSet.contains(extJsFileParams.getJsFile())) { - final AttachmentURLCommand attUrlCmd = getAttUrlCmd(attUrlCmdMock); - if (attUrlCmd.isAttachmentLink(extJsFileParams.getJsFile()) - || attUrlCmd.isOnDiskLink(extJsFileParams.getJsFile())) { + if (attUrlCommand.isAttachmentLink(extJsFileParams.getJsFile()) + || attUrlCommand.isOnDiskLink(extJsFileParams.getJsFile())) { extJSAttUrlSet.add(extJsFileParams.getJsFile()); + includeFrontendCss(extJsFileParams.getJsFile()); } - return generateScriptTagOnce(extJsFileParams, - generateUrl(extJsFileParams, attUrlCmd).orElse(null)); + return generateScriptTagOnce(extJsFileParams, generateUrl(extJsFileParams).orElse(null)); } else { LOGGER.debug("addExtJSfileOnce: skip already added {}", extJsFileParams.getJsFile()); } @@ -278,10 +272,31 @@ private String generateScriptTagOnce(@NotNull ExtJsFileParameter extJsFileParams } if (!displayedAll) { jsIncludes2 = ""; + } else if (!jsIncludes2.isEmpty()) { + jsIncludes2 = resolveCssIncludes(extJsFileParams.getJsFile()) + jsIncludes2; } return jsIncludes2; } + private String resolveCssIncludes(String jsFile) { + return frontendResolver.get(jsFile.trim()).stream() + .flatMap(resource -> resource.cssPaths().stream()) + .map(attUrlCommand::getDiskFileUrl) + .map(this::getCssLink) + .collect(joining()); + } + + private void includeFrontendCss(String jsFile) { + if (frontendResolver.isFrontendSource(jsFile.trim())) { + cssCommand.includeCSSPage(jsFile.trim()); + } + } + + private String getCssLink(String cssUrl) { + return "\n"; + } + private String buildNotFoundWarning(String jsFile) { return ""; } @@ -305,43 +320,34 @@ String getExtStringForJsFile(JsFileEntry jsFile) { } public List getAllRteContentJsFiles() { - return getAllRteContentJsFiles(null); - } - - List getAllRteContentJsFiles(@Nullable AttachmentURLCommand attUrlCmdMock) { - return getExtJsFileStream(attUrlCmdMock) + return getExtJsFileStream() .filter(fs -> fs.isRteContent() != JsIsRteContent.NO) .collect(Collectors.toList()); } public String getAllExternalJavaScriptFiles() { - return getAllExternalJavaScriptFiles(null); - } - - String getAllExternalJavaScriptFiles(@Nullable AttachmentURLCommand attUrlCmdMock) { - getExtJsFileStream(attUrlCmdMock); + getExtJsFileStream(); notifyExtJavaScriptFileListener(); final StringBuilder jsIncludesBuilder = generateJsImportString(); displayedAll = true; return jsIncludesBuilder.toString(); } - private Stream getExtJsFileStream(@Nullable AttachmentURLCommand attUrlCmdMock) { - ensureCollectAllJsExtFile(attUrlCmdMock); + private Stream getExtJsFileStream() { + ensureCollectAllJsExtFile(); return extJSfileSet.stream(); } - private void ensureCollectAllJsExtFile(AttachmentURLCommand attUrlCmdMock) { + private void ensureCollectAllJsExtFile() { if (!collectedAll) { - streamDocRefs2CollectJsExtFileObj() - .forEachOrdered(docRef -> addAllExtJSfilesFromDocRef(docRef, attUrlCmdMock)); + streamDocRefs2CollectJsExtFileObj().forEachOrdered(this::addAllExtJSfilesFromDocRef); collectedAll = true; } } private StringBuilder generateJsImportString() { final StringBuilder jsIncludesBuilder = new StringBuilder(); - StreamEx.of(getExtJsFileStream(null) + StreamEx.of(getExtJsFileStream() .filter(fs -> fs.isRteContent() != JsIsRteContent.ONLY) .map(this::getExtStringForJsFile)) .append(extJSnotFoundSet.stream().map(this::buildNotFoundWarning)) @@ -359,31 +365,31 @@ private Stream streamDocRefs2CollectJsExtFileObj() { } private Stream getCurrentDocRef() { - return StreamEx.of(getModelContext().getCurrentDocRef().toJavaUtil()); + return StreamEx.of(modelContext.getCurrentDocRef().toJavaUtil()); } private @NotNull Stream getLayoutPropDocRef() { - return StreamEx.of(getLayoutService().getLayoutPropDocRefForCurrentDoc()); + return StreamEx.of(layoutService.getLayoutPropDocRefForCurrentDoc()); } private @NotNull DocumentReference getCurrentPageTypeDocRef() { - return getObjectPageTypeUtils().getDocRefForPageType( - getPageTypeResolver().resolvePageTypeRefForCurrentDoc()); + return objectPageTypeUtils.getDocRefForPageType( + pageTypeResolver.resolvePageTypeRefForCurrentDoc()); } private Stream getCurrentSpacePreferencesDocRef() { - return StreamEx.of(getModelContext().getCurrentSpaceRef().toJavaUtil() + return StreamEx.of(modelContext.getCurrentSpaceRef().toJavaUtil() .map(spaceRef -> RefBuilder.from(spaceRef).doc("WebPreferences").build( DocumentReference.class))); } private @NotNull DocumentReference getXWikiPreferencesDocRef() { - return RefBuilder.from(getModelContext().getWikiRef()).space("XWiki") + return RefBuilder.from(modelContext.getWikiRef()).space("XWiki") .doc("XWikiPreferences").build(DocumentReference.class); } private Stream getSkinDocRef() { - return StreamEx.of(Optional.ofNullable(getModelContext().getXWikiContext()) + return StreamEx.of(Optional.ofNullable(modelContext.getXWikiContext()) .map(xcontext -> (VelocityContext) xcontext.get("vcontext")) .filter(vcontext -> vcontext.containsKey("skin_doc")) .map(vcontext -> ((Document) vcontext.get("skin_doc")).getDocumentReference())); @@ -398,60 +404,27 @@ private void notifyExtJavaScriptFileListener() { private Map getListenerMap() { try { - return Utils.getComponent(IWebUtilsService.class).lookupMap(IExtJSFilesListener.class); + return webUtilsService.lookupMap(IExtJSFilesListener.class); } catch (ComponentLookupException exp) { LOGGER.error("Failed to get IExtJSFilesListener components.", exp); } return Collections.emptyMap(); } - void addAllExtJSfilesFromDocRef(@NotNull DocumentReference docRef, - @Nullable AttachmentURLCommand attUrlCmdMock) { + void addAllExtJSfilesFromDocRef(@NotNull DocumentReference docRef) { checkNotNull(docRef); try { - XWikiObjectFetcher.on(getModelAccess().getDocument(docRef)) - .filter(getJavaScriptExternalFilesClassRef()) + XWikiObjectFetcher.on(modelAccess.getDocument(docRef)) + .filter(JavaScriptExternalFilesClass.CLASS_REF) .stream() - .map(ExternalJavaScriptFilesCommand.JS_FILE_ENTRY_CONVERTER.get()) + .map(jsFileEntryConverter) .filter(JsFileEntry::isValid) .forEachOrdered(jsFile -> addExtJSfileOnce( new ExtJsFileParameter.Builder() .setJsFileEntry(jsFile) - .build(), - attUrlCmdMock)); + .build())); } catch (DocumentNotExistsException nExExp) { LOGGER.info("addAllExtJSfilesFromDocRef skipping [{}] because: not exist.", docRef); } } - - @NotNull - AttachmentURLCommand getAttUrlCmd(@Nullable AttachmentURLCommand attUrlCmdMock) { - return Optional.ofNullable(attUrlCmdMock).orElse(new AttachmentURLCommand()); - } - - private @NotNull LayoutServiceRole getLayoutService() { - return Utils.getComponent(LayoutServiceRole.class); - } - - private IPageTypeResolverRole getPageTypeResolver() { - return Utils.getComponent(IPageTypeResolverRole.class); - } - - private IModelAccessFacade getModelAccess() { - return Utils.getComponent(IModelAccessFacade.class); - } - - private ModelContext getModelContext() { - return Utils.getComponent(ModelContext.class); - } - - private XObjectPageTypeUtilsRole getObjectPageTypeUtils() { - return Utils.getComponent(XObjectPageTypeUtilsRole.class); - } - - private ClassReference getJavaScriptExternalFilesClassRef() { - return Utils - .getComponent(CelementsClassDefinition.class, JavaScriptExternalFilesClass.CLASS_DEF_HINT) - .getClassReference(); - } } diff --git a/src/test/java/com/celements/javascript/FrontendResourceIncludeCssListenerTest.java b/src/test/java/com/celements/javascript/FrontendResourceIncludeCssListenerTest.java new file mode 100644 index 000000000..b2b9a601d --- /dev/null +++ b/src/test/java/com/celements/javascript/FrontendResourceIncludeCssListenerTest.java @@ -0,0 +1,65 @@ +package com.celements.javascript; + +import static org.easymock.EasyMock.*; +import static org.junit.Assert.*; + +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.stream.Stream; + +import org.apache.velocity.VelocityContext; +import org.junit.Before; +import org.junit.Test; + +import com.celements.common.test.AbstractComponentTest; +import com.celements.javascript.FrontendResourceResolver.FrontendResource; +import com.celements.model.access.IModelAccessFacade; +import com.celements.pagelayout.LayoutServiceRole; +import com.celements.web.css.CSS; +import com.celements.web.plugin.cmd.ExternalJavaScriptFilesCommand; +import com.xpn.xwiki.XWikiContext; + +public class FrontendResourceIncludeCssListenerTest extends AbstractComponentTest { + + private FrontendResourceIncludeCssListener listener; + private XWikiContext context; + + @Before + public void prepareTest() throws Exception { + registerComponentMocks( + ExternalJavaScriptFilesCommand.class, + FrontendResourceResolver.class, + IModelAccessFacade.class, + LayoutServiceRole.class); + context = getXContext(); + context.put("vcontext", new VelocityContext()); + listener = getBeanFactory().getBean(FrontendResourceIncludeCssListener.class); + } + + @Test + public void test_beforeAllExtFinish_includeFrontendCss() { + String sourcePath = ":frontend/progon/vue-poc/main.ts"; + expect(getMock(ExternalJavaScriptFilesCommand.class).streamExtJsFiles()) + .andReturn(Stream.of(sourcePath)).anyTimes(); + expect(getMock(FrontendResourceResolver.class).isFrontendSource(eq(sourcePath))) + .andReturn(true).anyTimes(); + expect(getMock(FrontendResourceResolver.class).get(eq(sourcePath))) + .andReturn(Optional.of(new FrontendResource("dist/vue-poc.BOsmCSyo.mjs", + Collections.singletonList("dist/assets/vue-poc-ahBOTvOT.css")))); + replayDefault(); + listener.beforeAllExtFinish(getMock(ExternalJavaScriptFilesCommand.class)); + verifyDefault(); + assertEquals(Collections.singletonList("dist/assets/vue-poc-ahBOTvOT.css"), + getIncludedCssBasePaths()); + } + + @SuppressWarnings("unchecked") + private List getIncludedCssBasePaths() { + VelocityContext vcontext = (VelocityContext) context.get("vcontext"); + List cssList = (List) vcontext.get("cel_css_list_page"); + return cssList.stream() + .map(CSS::getCssBasePath) + .toList(); + } +} diff --git a/src/test/java/com/celements/javascript/FrontendResourceResolverTest.java b/src/test/java/com/celements/javascript/FrontendResourceResolverTest.java new file mode 100644 index 000000000..daae329d7 --- /dev/null +++ b/src/test/java/com/celements/javascript/FrontendResourceResolverTest.java @@ -0,0 +1,92 @@ +package com.celements.javascript; + +import static java.nio.charset.StandardCharsets.*; +import static org.easymock.EasyMock.*; +import static org.junit.Assert.*; + +import java.util.Arrays; +import java.util.Collections; +import java.util.Optional; + +import org.junit.Before; +import org.junit.Test; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.Resource; +import org.springframework.core.io.support.ResourcePatternResolver; + +import com.celements.javascript.FrontendResourceResolver.FrontendResource; + +public class FrontendResourceResolverTest { + + private ResourcePatternResolver resourceLoader; + private FrontendResourceResolver resolver; + + @Before + public void prepareTest() { + resourceLoader = createMock(ResourcePatternResolver.class); + resolver = new FrontendResourceResolver(resourceLoader); + } + + @Test + public void test_get() throws Exception { + expect(resourceLoader.getResources("/resources/dist/.vite/manifest*.json")) + .andReturn(new Resource[] { manifestResource(""" + { + "src/main/frontend/progon/vue-poc/main.ts": { + "file": "vue-poc.BOsmCSyo.mjs", + "css": [ + "assets/vue-poc-ahBOTvOT.css", + "assets/shared.Df9z3kSS.css" + ] + } + } + """) }); + replay(resourceLoader); + + assertEquals(Optional.of(new FrontendResource( + "dist/vue-poc.BOsmCSyo.mjs", + Arrays.asList("dist/assets/vue-poc-ahBOTvOT.css", "dist/assets/shared.Df9z3kSS.css"))), + resolver.get(":frontend/progon/vue-poc/main.ts")); + + verify(resourceLoader); + } + + @Test + public void test_get_missingCss() throws Exception { + expect(resourceLoader.getResources("/resources/dist/.vite/manifest*.json")) + .andReturn(new Resource[] { manifestResource(""" + { + "src/main/frontend/progon/eventview/main.ts": { + "file": "eventview.Cq6C1_9z.mjs" + } + } + """) }); + replay(resourceLoader); + + assertEquals(Optional.of(new FrontendResource("dist/eventview.Cq6C1_9z.mjs", + Collections.emptyList())), resolver.get(":frontend/progon/eventview/main.ts")); + + verify(resourceLoader); + } + + @Test + public void test_get_notFound() throws Exception { + expect(resourceLoader.getResources("/resources/dist/.vite/manifest*.json")) + .andReturn(new Resource[] { manifestResource(""" + { + "src/main/frontend/progon/eventview/main.ts": { + "file": "eventview.Cq6C1_9z.mjs" + } + } + """) }); + replay(resourceLoader); + + assertEquals(Optional.empty(), resolver.get(":frontend/progon/vue-poc/main.ts")); + + verify(resourceLoader); + } + + private Resource manifestResource(String content) { + return new ByteArrayResource(content.getBytes(UTF_8)); + } +} diff --git a/src/test/java/com/celements/web/css/CSSEngineTest.java b/src/test/java/com/celements/web/css/CSSEngineTest.java index 11482f040..7303073fb 100644 --- a/src/test/java/com/celements/web/css/CSSEngineTest.java +++ b/src/test/java/com/celements/web/css/CSSEngineTest.java @@ -19,17 +19,23 @@ */ package com.celements.web.css; -import static com.celements.common.test.CelementsTestUtils.*; +import static org.easymock.EasyMock.*; import static org.junit.Assert.*; import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; import java.util.List; +import java.util.Optional; import org.apache.velocity.VelocityContext; import org.junit.Before; import org.junit.Test; import com.celements.common.test.AbstractComponentTest; +import com.celements.javascript.FrontendResourceResolver; +import com.celements.javascript.FrontendResourceResolver.FrontendResource; +import com.xpn.xwiki.XWiki; import com.xpn.xwiki.XWikiContext; import com.xpn.xwiki.objects.BaseObject; @@ -37,11 +43,14 @@ public class CSSEngineTest extends AbstractComponentTest { private CSSEngine cssEngine; private XWikiContext context; + private XWiki wiki; @Before public void setUp_CSSEngineTest() throws Exception { - context = getContext(); - cssEngine = (CSSEngine) CSSEngine.getCSSEngine(context); + registerComponentMocks(FrontendResourceResolver.class); + context = getXContext(); + wiki = getMock(XWiki.class); + cssEngine = getBeanFactory().getBean(CSSEngine.class); context.put("vcontext", new VelocityContext()); } @@ -63,6 +72,65 @@ public void testIncludeCSS_nullObject() { cssObj1)); } + @Test + public void testIncludeCSS_frontend() { + String sourcePath = ":frontend/progon/vue-poc/main.ts"; + expect(getMock(FrontendResourceResolver.class).isFrontendSource(eq(sourcePath))) + .andReturn(true); + expect(getMock(FrontendResourceResolver.class).get(eq(sourcePath))) + .andReturn(Optional.of(new FrontendResource( + "dist/vue-poc.BOsmCSyo.mjs", + Arrays.asList( + "dist/assets/vue-poc-ahBOTvOT.css", + "dist/assets/shared.Df9z3kSS.css")))); + + replayDefault(); + + List cssList = cssEngine.includeCSS(sourcePath, "css", null, context); + + assertEquals(2, cssList.size()); + assertEquals("dist/assets/vue-poc-ahBOTvOT.css", cssList.get(0).getCssBasePath()); + assertEquals("dist/assets/shared.Df9z3kSS.css", cssList.get(1).getCssBasePath()); + verifyDefault(); + } + + @Test + public void testIncludeCSS_frontend_getCSS() { + String sourcePath = ":frontend/progon/vue-poc/main.ts"; + expect(getMock(FrontendResourceResolver.class).isFrontendSource(eq(sourcePath))) + .andReturn(true); + expect(getMock(FrontendResourceResolver.class).get(eq(sourcePath))) + .andReturn(Optional.of(new FrontendResource("dist/vue-poc.BOsmCSyo.mjs", + Collections.singletonList("dist/assets/vue-poc-ahBOTvOT.css")))); + expect(wiki.getSkinFile(eq("dist/assets/vue-poc-ahBOTvOT.css"), eq(true), same(context))) + .andReturn("/appname/skin/resources/dist/assets/vue-poc-ahBOTvOT.css"); + + replayDefault(); + + List cssList = cssEngine.includeCSS(sourcePath, "css", null, context); + + assertEquals("/appname/file/resources/dist/assets/vue-poc-ahBOTvOT.css", + cssList.get(0).getCSS(context)); + verifyDefault(); + } + + @Test + public void testIncludeCSS_frontend_noCss() { + String sourcePath = ":frontend/progon/eventview/main.ts"; + expect(getMock(FrontendResourceResolver.class).isFrontendSource(eq(sourcePath))) + .andReturn(true); + expect(getMock(FrontendResourceResolver.class).get(eq(sourcePath))) + .andReturn(Optional.of(new FrontendResource("dist/eventview.Cq6C1_9z.mjs", + Collections.emptyList()))); + + replayDefault(); + + List cssList = cssEngine.includeCSS(sourcePath, "css", null, context); + + assertTrue(cssList.isEmpty()); + verifyDefault(); + } + // ***************************************************************** // * H E L P E R - M E T H O D S * // *****************************************************************/ diff --git a/src/test/java/com/celements/web/plugin/cmd/AttachmentURLCommandTest.java b/src/test/java/com/celements/web/plugin/cmd/AttachmentURLCommandTest.java index 6c27b5327..d1d4239b5 100644 --- a/src/test/java/com/celements/web/plugin/cmd/AttachmentURLCommandTest.java +++ b/src/test/java/com/celements/web/plugin/cmd/AttachmentURLCommandTest.java @@ -35,6 +35,7 @@ import com.celements.common.test.AbstractComponentTest; import com.celements.filebase.IAttachmentServiceRole; import com.celements.javascript.FrontendResourceResolver; +import com.celements.javascript.FrontendResourceResolver.FrontendResource; import com.celements.model.access.exception.AttachmentNotExistsException; import com.celements.url.UrlService; import com.xpn.xwiki.XWiki; @@ -153,7 +154,7 @@ public void test_getAttachmentURL_onDiskLink() { expect(wiki.getSkinFile(eq("celJS/bla.js"), eq(true), same(context))).andReturn(resultURL); expect(wiki.getResourceLastModificationDate(eq("resources/celJS/bla.js"))).andReturn( new Date()); - expect(getMock(FrontendResourceResolver.class).resolve(eq(input))) + expect(getMock(FrontendResourceResolver.class).get(eq(input))) .andReturn(Optional.empty()); replayDefault(); String attachmentURL = attUrlCmd.getAttachmentURL(input, context); @@ -166,8 +167,8 @@ public void test_getAttachmentURL_onDiskLink() { public void test_getAttachmentURL_frontend() { var input = ":frontend/bla.ts"; var resolved = "dist/bla.mjs"; - expect(getMock(FrontendResourceResolver.class).resolve(eq(input))) - .andReturn(Optional.of(resolved)); + expect(getMock(FrontendResourceResolver.class).get(eq(input))) + .andReturn(Optional.of(new FrontendResource(resolved, java.util.List.of()))); expect(wiki.getSkinFile(eq(resolved), eq(true), same(context))) .andReturn("/appname/skin/resources/dist/bla.mjs"); replayDefault(); diff --git a/src/test/java/com/celements/web/plugin/cmd/CssCommandTest.java b/src/test/java/com/celements/web/plugin/cmd/CssCommandTest.java index 7352d8de2..0163b342e 100644 --- a/src/test/java/com/celements/web/plugin/cmd/CssCommandTest.java +++ b/src/test/java/com/celements/web/plugin/cmd/CssCommandTest.java @@ -13,6 +13,9 @@ import com.celements.common.test.AbstractComponentTest; import com.celements.css.ICssExtensionRole; +import com.celements.javascript.FrontendResourceResolver; +import com.celements.model.access.IModelAccessFacade; +import com.celements.pagelayout.LayoutServiceRole; import com.celements.web.css.CSS; import com.celements.web.css.CSSString; @@ -22,11 +25,15 @@ public class CssCommandTest extends AbstractComponentTest { @Before public void setUp_CssCommandTest() throws Exception { - cssCommand = new CssCommand(); + registerComponentMocks( + FrontendResourceResolver.class, + IModelAccessFacade.class, + LayoutServiceRole.class); } @Test public void test_includeApplicationDefaultCSS_emptyList() { + cssCommand = getBeanFactory().getBean(CssCommand.class); replayDefault(); List cssList = cssCommand.includeApplicationDefaultCSS(); assertNotNull(cssList); @@ -37,6 +44,7 @@ public void test_includeApplicationDefaultCSS_emptyList() { public void test_includeApplicationDefaultCSS_registerMockComponent_emptyList() throws Exception { ICssExtensionRole testCssExtMock = registerComponentMock(ICssExtensionRole.class, "testCssExt"); expect(testCssExtMock.getCssList()).andReturn(Collections.emptyList()).once(); + cssCommand = getBeanFactory().getBean(CssCommand.class); replayDefault(); List cssList = cssCommand.includeApplicationDefaultCSS(); assertNotNull(cssList); @@ -48,6 +56,7 @@ public void test_includeApplicationDefaultCSS_registerMockComponent_oneElem() th ICssExtensionRole testCssExtMock = registerComponentMock(ICssExtensionRole.class, "testCssExt"); expect(testCssExtMock.getCssList()).andReturn(Arrays.asList(new CSSString( ":celRes/test.css", getContext()))).once(); + cssCommand = getBeanFactory().getBean(CssCommand.class); replayDefault(); List cssList = cssCommand.includeApplicationDefaultCSS(); assertNotNull(cssList); diff --git a/src/test/java/com/celements/web/plugin/cmd/ExternalJavaScriptFilesCommandTest.java b/src/test/java/com/celements/web/plugin/cmd/ExternalJavaScriptFilesCommandTest.java index 698f58799..34639ea82 100644 --- a/src/test/java/com/celements/web/plugin/cmd/ExternalJavaScriptFilesCommandTest.java +++ b/src/test/java/com/celements/web/plugin/cmd/ExternalJavaScriptFilesCommandTest.java @@ -36,6 +36,8 @@ import com.celements.common.test.AbstractComponentTest; import com.celements.javascript.ExtJsFileParameter; import com.celements.javascript.ExtJsFileParameter.Builder; +import com.celements.javascript.FrontendResourceResolver; +import com.celements.javascript.FrontendResourceResolver.FrontendResource; import com.celements.javascript.JavaScriptExternalFilesClass; import com.celements.javascript.JsFileEntry; import com.celements.javascript.JsIsRteContent; @@ -46,34 +48,38 @@ import com.celements.pagetype.PageTypeReference; import com.celements.pagetype.service.IPageTypeResolverRole; import com.xpn.xwiki.XWikiContext; -import com.xpn.xwiki.XWikiException; import com.xpn.xwiki.doc.XWikiDocument; import com.xpn.xwiki.objects.BaseObject; public class ExternalJavaScriptFilesCommandTest extends AbstractComponentTest { - private ExternalJavaScriptFilesCommand command = null; - private XWikiContext context = null; - private AttachmentURLCommand attUrlCmd = null; + private ExternalJavaScriptFilesCommand command; + private XWikiContext context; + private AttachmentURLCommand attUrlCmd; private IPageTypeResolverRole pageTypeResolverMock; private IModelAccessFacade modelAccessMock; private LayoutServiceRole pageLayoutCmdMock; + private FrontendResourceResolver resolver; @Before - public void setUp_ExternalJavaScriptFilesCommandTest() throws Exception { + public void prepareTest() throws Exception { context = getXContext(); context.put("vcontext", new VelocityContext()); modelAccessMock = registerComponentMock(IModelAccessFacade.class); pageTypeResolverMock = registerComponentMock(IPageTypeResolverRole.class); pageLayoutCmdMock = registerComponentMock(LayoutServiceRole.class); - attUrlCmd = createDefaultMock(AttachmentURLCommand.class); - command = new ExternalJavaScriptFilesCommand(); + attUrlCmd = registerComponentMock(AttachmentURLCommand.class); + registerComponentMock(CssCommand.class); + resolver = registerComponentMock(FrontendResourceResolver.class); + command = getBeanFactory().getBean(ExternalJavaScriptFilesCommand.class); } @Test public void test_addExtJSfileOnce_beforeGetAll() { String file = ":celJS/prototype.js"; String filePath = "/file/celJS/prototype.js?version=12344"; + expect(resolver.isFrontendSource(eq(file))).andReturn(false).anyTimes(); + expect(resolver.get(eq(file))).andReturn(Optional.empty()).anyTimes(); expect(attUrlCmd.getAttachmentURL(file, null, (String) null)) .andReturn(Optional.of(UriComponentsBuilder.fromUriString(filePath).build())); expect(attUrlCmd.isAttachmentLink(eq(file))).andReturn(false).atLeastOnce(); @@ -81,13 +87,15 @@ public void test_addExtJSfileOnce_beforeGetAll() { replayDefault(); assertEquals("", command.addExtJSfileOnce(new ExtJsFileParameter.Builder() .setJsFile(file) - .build(), attUrlCmd)); + .build())); verifyDefault(); } @Test public void test_addExtJSfileOnce_beforeGetAll_fileNotFound() { String fileNotFound = "Content.WebHome;blabla.js"; + expect(resolver.isFrontendSource(eq(fileNotFound))).andReturn(false).anyTimes(); + expect(resolver.get(eq(fileNotFound))).andReturn(Optional.empty()).anyTimes(); expect(attUrlCmd.getAttachmentURL(fileNotFound, null, (String) null)) .andReturn(Optional.empty()).atLeastOnce(); expect(attUrlCmd.isAttachmentLink(eq(fileNotFound))).andReturn(true).atLeastOnce(); @@ -95,13 +103,80 @@ public void test_addExtJSfileOnce_beforeGetAll_fileNotFound() { replayDefault(); assertEquals("", command.addExtJSfileOnce(new ExtJsFileParameter.Builder() .setJsFile(fileNotFound) - .build(), attUrlCmd)); + .build())); verifyDefault(); } @Test - public void test_addExtJSfileOnce_afterGetAll() throws XWikiException { + public void test_streamExtJsFiles() { + CssCommand cssCommand = getMock(CssCommand.class); + String frontendFile = ":frontend/progon/vue-poc/main.ts"; + String legacyFile = ":celJS/prototype.js"; + String otherFrontendFile = ":frontend/progon/eventview/main.ts"; + expect(resolver.isFrontendSource(eq(frontendFile))).andReturn(true).anyTimes(); + expect(resolver.isFrontendSource(eq(legacyFile))).andReturn(false).anyTimes(); + expect(resolver.isFrontendSource(eq(otherFrontendFile))).andReturn(true).anyTimes(); + expect(resolver.get(eq(frontendFile))).andReturn(Optional.empty()).anyTimes(); + expect(resolver.get(eq(otherFrontendFile))).andReturn(Optional.empty()).anyTimes(); + expectAddOnDiskFile(frontendFile, "/file/resources/dist/vue-poc.mjs"); + expectAddOnDiskFile(legacyFile, "/file/resources/celJS/prototype.js"); + expectAddOnDiskFile(otherFrontendFile, "/file/resources/dist/eventview.mjs"); + expect(cssCommand.includeCSSPage(eq(frontendFile))).andReturn(List.of()); + expect(cssCommand.includeCSSPage(eq(otherFrontendFile))).andReturn(List.of()); + + replayDefault(); + + command.addExtJSfileOnce(new ExtJsFileParameter.Builder() + .setJsFile(frontendFile) + .build()); + command.addExtJSfileOnce(new ExtJsFileParameter.Builder() + .setJsFile(legacyFile) + .build()); + command.addExtJSfileOnce(new ExtJsFileParameter.Builder() + .setJsFile(otherFrontendFile) + .build()); + + assertEquals(Arrays.asList(frontendFile, legacyFile, otherFrontendFile), + command.streamExtJsFiles().toList()); + verifyDefault(); + } + + @Test + public void test_addExtJSfileOnce_afterGetAll_frontendCss() { + CssCommand cssCommand = getMock(CssCommand.class); + String frontendFile = ":frontend/progon/vue-poc/main.ts"; + String jsUrl = "/file/resources/dist/vue-poc.mjs"; + String cssPath = "dist/assets/vue-poc.css"; + String cssUrl = "/file/resources/dist/assets/vue-poc.css"; + expect(resolver.isFrontendSource(eq(frontendFile))).andReturn(true).anyTimes(); + expect(resolver.get(eq(frontendFile))) + .andReturn(Optional.of(new FrontendResource("dist/vue-poc.mjs", List.of(cssPath)))); + expect(attUrlCmd.isAttachmentLink(eq(frontendFile))).andReturn(false).atLeastOnce(); + expect(attUrlCmd.isOnDiskLink(eq(frontendFile))).andReturn(true).atLeastOnce(); + expect(cssCommand.includeCSSPage(eq(frontendFile))).andReturn(List.of()); + expect(attUrlCmd.getAttachmentURL(frontendFile, "file", (String) null)) + .andReturn(Optional.of(fromUriString(jsUrl).build())); + expect(attUrlCmd.getDiskFileUrl(eq(cssPath))).andReturn(cssUrl); + + replayDefault(); + + command.injectDisplayAll(true); + String includes = command.addExtJSfileOnce(new ExtJsFileParameter.Builder() + .setJsFile(frontendFile) + .setAction("file") + .build()); + + assertEquals("\n" + + "", includes); + verifyDefault(); + } + + @Test + public void test_addExtJSfileOnce_afterGetAll() { String file = "/skin/resources/celJS/prototype.js"; + expect(resolver.isFrontendSource(eq(file))).andReturn(false).anyTimes(); + expect(resolver.get(eq(file))).andReturn(Optional.empty()).anyTimes(); expect(attUrlCmd.getAttachmentURL(file, null, (String) null)) .andReturn(Optional.of(fromUriString(file).build())).atLeastOnce(); expect(attUrlCmd.isAttachmentLink(eq(file))).andReturn(false).atLeastOnce(); @@ -112,14 +187,16 @@ public void test_addExtJSfileOnce_afterGetAll() throws XWikiException { .setJsFile(file) .build(); assertEquals("", - command.addExtJSfileOnce(extJsFileParam, attUrlCmd)); - assertEquals("", command.addExtJSfileOnce(extJsFileParam, attUrlCmd)); + command.addExtJSfileOnce(extJsFileParam)); + assertEquals("", command.addExtJSfileOnce(extJsFileParam)); verifyDefault(); } @Test - public void test_addExtJSfileOnce_afterGetAll_action() throws XWikiException { + public void test_addExtJSfileOnce_afterGetAll_action() { String file = "/file/resources/celJS/prototype.js"; + expect(resolver.isFrontendSource(eq(file))).andReturn(false).anyTimes(); + expect(resolver.get(eq(file))).andReturn(Optional.empty()).anyTimes(); expect(attUrlCmd.getAttachmentURL(file, "file", (String) null)) .andReturn(Optional.of(fromUriString(file).build())).atLeastOnce(); expect(attUrlCmd.isAttachmentLink(eq(file))).andReturn(false).atLeastOnce(); @@ -131,14 +208,16 @@ public void test_addExtJSfileOnce_afterGetAll_action() throws XWikiException { .setAction("file") .build(); assertEquals("", - command.addExtJSfileOnce(extJsFile, attUrlCmd)); - assertEquals("", command.addExtJSfileOnce(extJsFile, attUrlCmd)); + command.addExtJSfileOnce(extJsFile)); + assertEquals("", command.addExtJSfileOnce(extJsFile)); verifyDefault(); } @Test - public void test_addExtJSfileOnce_afterGetAll_action_params() throws XWikiException { + public void test_addExtJSfileOnce_afterGetAll_action_params() { String file = "/file/resources/celJS/prototype.js"; + expect(resolver.isFrontendSource(eq(file))).andReturn(false).anyTimes(); + expect(resolver.get(eq(file))).andReturn(Optional.empty()).anyTimes(); expect(attUrlCmd.getAttachmentURL(file, "file", "me=blu")) .andReturn(Optional.of(fromUriString(file).queryParam("me", "blu").build())).atLeastOnce(); expect(attUrlCmd.isAttachmentLink(eq(file))).andReturn(false).atLeastOnce(); @@ -150,19 +229,21 @@ public void test_addExtJSfileOnce_afterGetAll_action_params() throws XWikiExcept .setJsFile(file) .setAction("file") .setQueryString("me=blu") - .build(), attUrlCmd)); + .build())); assertEquals("", command.addExtJSfileOnce(new ExtJsFileParameter.Builder() .setJsFile(file) .setAction("file") .setQueryString("me=blu") - .build(), attUrlCmd)); + .build())); verifyDefault(); } @Test - public void test_addExtJSfileOnce_afterGetAll_action_params_onDisk() throws XWikiException { + public void test_addExtJSfileOnce_afterGetAll_action_params_onDisk() { String file = ":celJS/prototype.js"; String fileURL = "/file/resources/celJS/prototype.js?version=201507061937"; + expect(resolver.isFrontendSource(eq(file))).andReturn(false).anyTimes(); + expect(resolver.get(eq(file))).andReturn(Optional.empty()).anyTimes(); expect(attUrlCmd.getAttachmentURL(file, "file", "me=blu")) .andReturn(Optional.of(fromUriString(fileURL).queryParam("me", "blu").build())); expect(attUrlCmd.isAttachmentLink(eq(file))).andReturn(false).atLeastOnce(); @@ -175,14 +256,16 @@ public void test_addExtJSfileOnce_afterGetAll_action_params_onDisk() throws XWik .setQueryString("me=blu") .build(); assertEquals("", - command.addExtJSfileOnce(extJsFileParam, attUrlCmd)); - assertEquals("", command.addExtJSfileOnce(extJsFileParam, attUrlCmd)); + command.addExtJSfileOnce(extJsFileParam)); + assertEquals("", command.addExtJSfileOnce(extJsFileParam)); verifyDefault(); } @Test - public void test_addExtJSfileOnce_afterGetAll_versioning() throws XWikiException { + public void test_addExtJSfileOnce_afterGetAll_versioning() { String file = "celJS/prototype.js?version=20110401182200"; + expect(resolver.isFrontendSource(eq(file))).andReturn(false).anyTimes(); + expect(resolver.get(eq(file))).andReturn(Optional.empty()).anyTimes(); expect(attUrlCmd.getAttachmentURL(file, null, (String) null)) .andReturn(Optional.of(fromUriString(file).build())).atLeastOnce(); expect(attUrlCmd.isAttachmentLink(eq(file))).andReturn(false).atLeastOnce(); @@ -192,10 +275,10 @@ public void test_addExtJSfileOnce_afterGetAll_versioning() throws XWikiException assertEquals("", command.addExtJSfileOnce(new ExtJsFileParameter.Builder() .setJsFile(file) - .build(), attUrlCmd)); + .build())); assertEquals("", command.addExtJSfileOnce(new ExtJsFileParameter.Builder() .setJsFile(file) - .build(), attUrlCmd)); + .build())); verifyDefault(); } @@ -266,6 +349,8 @@ public void test_getAllRteContentJsFiles_Both() throws Exception { BaseObject extJsFileObj = new BaseObject(); extJsFileObj.setXClassReference(JavaScriptExternalFilesClass.CLASS_REF); expect(attUrlCmd.isAttachmentLink(eq(attExtFilePath))).andReturn(true).atLeastOnce(); + expect(resolver.isFrontendSource(eq(attExtFilePath))).andReturn(false).anyTimes(); + expect(resolver.get(eq(attExtFilePath))).andReturn(Optional.empty()).anyTimes(); expect(attUrlCmd.getAttachmentURL(attExtFilePath, null, (String) null)) .andReturn(Optional.of(fromUriString(attExtFileUrl).build())).atLeastOnce(); extJsFileObj.setStringValue(JavaScriptExternalFilesClass.FIELD_FILEPATH.getName(), @@ -292,7 +377,7 @@ public void test_getAllRteContentJsFiles_Both() throws Exception { XWikiDocument pageTypesDoc = new XWikiDocument(pageTypesDocRef); expect(modelAccessMock.getDocument(eq(pageTypesDocRef))).andReturn(pageTypesDoc).atLeastOnce(); replayDefault(); - List jsFileList = command.getAllRteContentJsFiles(attUrlCmd); + List jsFileList = command.getAllRteContentJsFiles(); verifyDefault(); assertEquals(1, jsFileList.size()); assertEquals(attExtFileUrl, jsFileList.get(0).getFilepath()); @@ -305,6 +390,8 @@ public void test_getAllRteContentJsFiles_only() throws Exception { BaseObject extJsFileObj = new BaseObject(); extJsFileObj.setXClassReference(JavaScriptExternalFilesClass.CLASS_REF); expect(attUrlCmd.isAttachmentLink(eq(attExtFilePath))).andReturn(true).atLeastOnce(); + expect(resolver.isFrontendSource(eq(attExtFilePath))).andReturn(false).anyTimes(); + expect(resolver.get(eq(attExtFilePath))).andReturn(Optional.empty()).anyTimes(); expect(attUrlCmd.getAttachmentURL(attExtFilePath, null, (String) null)) .andReturn(Optional.of(fromUriString(attExtFileUrl).build())).atLeastOnce(); extJsFileObj.setStringValue(JavaScriptExternalFilesClass.FIELD_FILEPATH.getName(), @@ -331,7 +418,7 @@ public void test_getAllRteContentJsFiles_only() throws Exception { XWikiDocument pageTypesDoc = new XWikiDocument(pageTypesDocRef); expect(modelAccessMock.getDocument(eq(pageTypesDocRef))).andReturn(pageTypesDoc).atLeastOnce(); replayDefault(); - List jsFileList = command.getAllRteContentJsFiles(attUrlCmd); + List jsFileList = command.getAllRteContentJsFiles(); verifyDefault(); assertEquals(1, jsFileList.size()); assertEquals(attExtFileUrl, jsFileList.get(0).getFilepath()); @@ -344,6 +431,8 @@ public void test_getAllRteContentJsFiles_No() throws Exception { BaseObject extJsFileObj = new BaseObject(); extJsFileObj.setXClassReference(JavaScriptExternalFilesClass.CLASS_REF); expect(attUrlCmd.isAttachmentLink(eq(attExtFilePath))).andReturn(true).atLeastOnce(); + expect(resolver.isFrontendSource(eq(attExtFilePath))).andReturn(false).anyTimes(); + expect(resolver.get(eq(attExtFilePath))).andReturn(Optional.empty()).anyTimes(); expect(attUrlCmd.getAttachmentURL(attExtFilePath, null, (String) null)) .andReturn(Optional.of(fromUriString(attExtFileUrl).build())).atLeastOnce(); extJsFileObj.setStringValue(JavaScriptExternalFilesClass.FIELD_FILEPATH.getName(), @@ -370,14 +459,16 @@ public void test_getAllRteContentJsFiles_No() throws Exception { XWikiDocument pageTypesDoc = new XWikiDocument(pageTypesDocRef); expect(modelAccessMock.getDocument(eq(pageTypesDocRef))).andReturn(pageTypesDoc).atLeastOnce(); replayDefault(); - List jsFileList = command.getAllRteContentJsFiles(attUrlCmd); + List jsFileList = command.getAllRteContentJsFiles(); verifyDefault(); assertTrue(jsFileList.isEmpty()); } @Test - public void test_addExtJSfileOnce_afterGetAll_fileNotFound_url() throws XWikiException { + public void test_addExtJSfileOnce_afterGetAll_fileNotFound_url() { String fileNotFound = "/download/Content/WebHome/blabla.js"; + expect(resolver.isFrontendSource(eq(fileNotFound))).andReturn(false).anyTimes(); + expect(resolver.get(eq(fileNotFound))).andReturn(Optional.empty()).anyTimes(); expect(attUrlCmd.getAttachmentURL(fileNotFound, null, (String) null)) .andReturn(Optional.empty()).atLeastOnce(); expect(attUrlCmd.isAttachmentLink(eq(fileNotFound))).andReturn(false).atLeastOnce(); @@ -387,16 +478,18 @@ public void test_addExtJSfileOnce_afterGetAll_fileNotFound_url() throws XWikiExc assertEquals("", command.addExtJSfileOnce(new ExtJsFileParameter.Builder() .setJsFile(fileNotFound) - .build(), attUrlCmd)); + .build())); assertEquals("", command.addExtJSfileOnce(new ExtJsFileParameter.Builder() .setJsFile(fileNotFound) - .build(), attUrlCmd)); + .build())); verifyDefault(); } @Test - public void test_addExtJSfileOnce_afterGetAll_fileNotFound_attUrl() throws XWikiException { + public void test_addExtJSfileOnce_afterGetAll_fileNotFound_attUrl() { String fileNotFound = "Content.WebHome;blabla.js"; + expect(resolver.isFrontendSource(eq(fileNotFound))).andReturn(false).anyTimes(); + expect(resolver.get(eq(fileNotFound))).andReturn(Optional.empty()).anyTimes(); expect(attUrlCmd.getAttachmentURL(fileNotFound, null, (String) null)) .andReturn(Optional.empty()).atLeastOnce(); expect(attUrlCmd.isAttachmentLink(eq(fileNotFound))).andReturn(true).atLeastOnce(); @@ -406,10 +499,10 @@ public void test_addExtJSfileOnce_afterGetAll_fileNotFound_attUrl() throws XWiki assertEquals("", command.addExtJSfileOnce(new ExtJsFileParameter.Builder() .setJsFile(fileNotFound) - .build(), attUrlCmd)); + .build())); assertEquals("", command.addExtJSfileOnce(new ExtJsFileParameter.Builder() .setJsFile(fileNotFound) - .build(), attUrlCmd)); + .build())); verifyDefault(); } @@ -421,7 +514,7 @@ public void test_addAllExtJSfilesFromDocRef_emptyDoc() throws Exception { expect(modelAccessMock.getDocument(eq(contextDocRef))).andReturn(contextDoc).atLeastOnce(); context.setDoc(contextDoc); replayDefault(); - command.addAllExtJSfilesFromDocRef(contextDocRef, attUrlCmd); + command.addAllExtJSfilesFromDocRef(contextDocRef); verifyDefault(); } @@ -442,11 +535,11 @@ public void test_addAllExtJSfilesFromDocRef_sync() throws Exception { expect(modelAccessMock.getDocument(eq(contextDocRef))).andReturn(contextDoc).atLeastOnce(); context.setDoc(contextDoc); replayDefault(); - command.addAllExtJSfilesFromDocRef(contextDocRef, attUrlCmd); + command.addAllExtJSfilesFromDocRef(contextDocRef); assertEquals("must be already added by addAllExtJSfilesFromDocRef", "", command.addExtJSfileOnce(new ExtJsFileParameter.Builder() .setJsFile(filePath) - .build(), attUrlCmd)); + .build())); verifyDefault(); } @@ -470,12 +563,12 @@ public void test_addAllExtJSfilesFromDocRef_defer() throws Exception { expect(modelAccessMock.getDocument(eq(contextDocRef))).andReturn(contextDoc).atLeastOnce(); context.setDoc(contextDoc); replayDefault(); - command.addAllExtJSfilesFromDocRef(contextDocRef, attUrlCmd); + command.addAllExtJSfilesFromDocRef(contextDocRef); assertEquals("must be already added by addAllExtJSfilesFromDocRef", "", command.addExtJSfileOnce(new ExtJsFileParameter.Builder() .setJsFile(filePath) .setLoadMode(loadMode) - .build(), attUrlCmd)); + .build())); verifyDefault(); } @@ -499,12 +592,12 @@ public void test_addAllExtJSfilesFromDocRef_async() throws Exception { expect(modelAccessMock.getDocument(eq(contextDocRef))).andReturn(contextDoc).atLeastOnce(); context.setDoc(contextDoc); replayDefault(); - command.addAllExtJSfilesFromDocRef(contextDocRef, attUrlCmd); + command.addAllExtJSfilesFromDocRef(contextDocRef); assertEquals("must be already added by addAllExtJSfilesFromDocRef", "", command.addExtJSfileOnce(new ExtJsFileParameter.Builder() .setJsFile(filePath) .setLoadMode(loadMode) - .build(), attUrlCmd)); + .build())); verifyDefault(); } @@ -515,7 +608,7 @@ public void test_addAllExtJSfilesFromDocRef_notExists() throws Exception { expect(modelAccessMock.getDocument(eq(contextDocRef))) .andThrow(new DocumentNotExistsException(contextDocRef)).atLeastOnce(); replayDefault(); - command.addAllExtJSfilesFromDocRef(contextDocRef, attUrlCmd); + command.addAllExtJSfilesFromDocRef(contextDocRef); verifyDefault(); } @@ -526,11 +619,15 @@ public void test_addExtJSfileOnce_beforeGetAll_double() throws Exception { XWikiDocument contextDoc = new XWikiDocument(contextDocRef); context.setDoc(contextDoc); String fileNotFound = "celJS/blabla.js"; + expect(resolver.isFrontendSource(eq(fileNotFound))).andReturn(false).anyTimes(); + expect(resolver.get(eq(fileNotFound))).andReturn(Optional.empty()).anyTimes(); expect(attUrlCmd.getAttachmentURL(fileNotFound, null, (String) null)) .andReturn(Optional.empty()).atLeastOnce(); expect(attUrlCmd.isAttachmentLink(eq(fileNotFound))).andReturn(false).atLeastOnce(); expect(attUrlCmd.isOnDiskLink(eq(fileNotFound))).andReturn(true).atLeastOnce(); String file = "/skin/resources/celJS/prototype.js?version=20110401120000"; + expect(resolver.isFrontendSource(eq(file))).andReturn(false).anyTimes(); + expect(resolver.get(eq(file))).andReturn(Optional.empty()).anyTimes(); expect(attUrlCmd.getAttachmentURL(file, null, (String) null)) .andReturn(Optional.of(fromUriString(file).build())).atLeastOnce(); expect(attUrlCmd.isAttachmentLink(eq(file))).andReturn(false).atLeastOnce(); @@ -567,10 +664,10 @@ public void test_addExtJSfileOnce_beforeGetAll_double() throws Exception { .setJsFile(fileNotFound) .build(); replayDefault(); - assertEquals("", command.addExtJSfileOnce(fileParams, attUrlCmd)); - assertEquals("", command.addExtJSfileOnce(fileParams, attUrlCmd)); - assertEquals("", command.addExtJSfileOnce(fileNotFoundParams, attUrlCmd)); - String allStr = command.getAllExternalJavaScriptFiles(attUrlCmd); + assertEquals("", command.addExtJSfileOnce(fileParams)); + assertEquals("", command.addExtJSfileOnce(fileParams)); + assertEquals("", command.addExtJSfileOnce(fileNotFoundParams)); + String allStr = command.getAllExternalJavaScriptFiles(); assertEquals("\n" + "\n", allStr); verifyDefault(); @@ -583,11 +680,15 @@ public void test_addExtJSfileOnce_beforeGetAll_explicitAndImplicit_double() thro XWikiDocument contextDoc = new XWikiDocument(contextDocRef); context.setDoc(contextDoc); String fileNotFound = ":celJS/blabla.js"; + expect(resolver.isFrontendSource(eq(fileNotFound))).andReturn(false).anyTimes(); + expect(resolver.get(eq(fileNotFound))).andReturn(Optional.empty()).anyTimes(); expect(attUrlCmd.getAttachmentURL(fileNotFound, null, (String) null)) .andReturn(Optional.empty()).atLeastOnce(); expect(attUrlCmd.isAttachmentLink(eq(fileNotFound))).andReturn(false).atLeastOnce(); expect(attUrlCmd.isOnDiskLink(eq(fileNotFound))).andReturn(true).atLeastOnce(); String attFileURL = ":celJS/prototype.js"; + expect(resolver.isFrontendSource(eq(attFileURL))).andReturn(false).anyTimes(); + expect(resolver.get(eq(attFileURL))).andReturn(Optional.empty()).anyTimes(); expect(attUrlCmd.isAttachmentLink(eq(attFileURL))).andReturn(false).atLeastOnce(); expect(attUrlCmd.isOnDiskLink(eq(attFileURL))).andReturn(true).atLeastOnce(); String file2 = "/file/celJS/prototype.js?version=20110401120000"; @@ -626,15 +727,15 @@ public void test_addExtJSfileOnce_beforeGetAll_explicitAndImplicit_double() thro assertEquals("", command.addExtJSfileOnce(new ExtJsFileParameter.Builder() .setJsFile(attFileURL) .setAction("file") - .build(), attUrlCmd)); + .build())); Builder paramBuilder = new ExtJsFileParameter.Builder(); assertEquals("", command.addExtJSfileOnce(paramBuilder .setJsFile(attFileURL) - .build(), attUrlCmd)); + .build())); assertEquals("", command.addExtJSfileOnce(paramBuilder .setJsFile(fileNotFound) - .build(), attUrlCmd)); - String allStr = command.getAllExternalJavaScriptFiles(attUrlCmd); + .build())); + String allStr = command.getAllExternalJavaScriptFiles(); assertEquals("\n" + "\n", allStr); @@ -653,7 +754,7 @@ public void test_addLazyExtJSfile() { + "", command.getLazyLoadTag(new ExtJsFileParameter.Builder() .setJsFile(jsFile) - .build(), attUrlCmd)); + .build())); verifyDefault(); } @@ -670,7 +771,7 @@ public void test_addLazyExtJSfile_action() { command.getLazyLoadTag(new ExtJsFileParameter.Builder() .setJsFile(jsFile) .setAction(action) - .build(), attUrlCmd)); + .build())); verifyDefault(); } @@ -688,7 +789,7 @@ public void test_addLazyExtJSfile_action_params() { .setJsFile(jsFile) .setAction(action) .setQueryString("me=blu") - .build(), attUrlCmd)); + .build())); verifyDefault(); } @@ -707,8 +808,15 @@ public void test_addLazyExtJSfile_action_params_onDisk() { .setJsFile(jsFile) .setAction(action) .setQueryString("me=blu") - .build(), attUrlCmd)); + .build())); verifyDefault(); } + private void expectAddOnDiskFile(String file, String url) { + expect(attUrlCmd.isAttachmentLink(eq(file))).andReturn(false).atLeastOnce(); + expect(attUrlCmd.isOnDiskLink(eq(file))).andReturn(true).atLeastOnce(); + expect(attUrlCmd.getAttachmentURL(file, null, (String) null)) + .andReturn(Optional.of(fromUriString(url).build())); + } + } From a5a03189978df35070c929f27c7164a29fc67434 Mon Sep 17 00:00:00 2001 From: Marc Sladek Date: Wed, 8 Jul 2026 21:18:26 +0200 Subject: [PATCH 2/2] FrontendResourceResolver support vite manifest imports --- .../javascript/FrontendResourceResolver.java | 126 ++++++++++++------ .../FrontendResourceResolverTest.java | 36 +++++ 2 files changed, 123 insertions(+), 39 deletions(-) diff --git a/src/main/java/com/celements/javascript/FrontendResourceResolver.java b/src/main/java/com/celements/javascript/FrontendResourceResolver.java index d379c932e..0d787e557 100644 --- a/src/main/java/com/celements/javascript/FrontendResourceResolver.java +++ b/src/main/java/com/celements/javascript/FrontendResourceResolver.java @@ -1,12 +1,13 @@ package com.celements.javascript; +import static com.google.common.base.Predicates.*; import static java.util.Objects.*; import java.io.IOException; import java.io.InputStream; import java.util.List; import java.util.Map; -import java.util.Map.Entry; +import java.util.Objects; import java.util.Optional; import java.util.function.Supplier; import java.util.stream.Stream; @@ -24,6 +25,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.google.common.base.Suppliers; +import one.util.streamex.EntryStream; import one.util.streamex.StreamEx; /** @@ -59,11 +61,11 @@ public FrontendResourceResolver(ResourcePatternResolver resourceLoader) { @PostConstruct public void init() { - manifest.get(); + getManifest(); } - public boolean isFrontendSource(String path) { - return requireNonNullElse(path, "").trim().startsWith(SOURCE_PREFIX); + public Map getManifest() { + return manifest.get(); } /** @@ -76,7 +78,11 @@ public boolean isFrontendSource(String path) { * "dist/file.a8b3.mjs" and ["dist/assets/file.a8b3.css"] */ public Optional get(String sourcePath) { - return Optional.ofNullable(manifest.get().get(sourcePath)); + return Optional.ofNullable(getManifest().get(sourcePath)); + } + + public boolean isFrontendSource(String path) { + return requireNonNullElse(path, "").trim().startsWith(SOURCE_PREFIX); } private Map readManifestFiles() { @@ -89,10 +95,7 @@ private Map readManifestFiles() { var map = StreamEx.of(resources) .filter(Resource::exists) .flatMap(this::readJson) - .flatMap(rootNode -> StreamEx.of(rootNode.fields())) - .mapToEntry(Entry::getKey, Entry::getValue) - .flatMapKeys(this::prefixKey) - .flatMapValues(this::extractResource) + .flatMapToEntry(json -> new ViteManifest(json).frontendResources()) .toImmutableMap(); // ISE on duplicate keys, we expect unique source files LOGGER.info("readManifest - {}", map); return map; @@ -107,40 +110,85 @@ private Stream readJson(Resource resource) { } } - private Stream prefixKey(String key) { - return Stream.ofNullable(key) - .filter(k -> k.startsWith(MANIFEST_KEY_PREFIX)) - .map(k -> k.replaceFirst(MANIFEST_KEY_PREFIX, SOURCE_PREFIX)); - } + public record FrontendResource(String jsPath, List cssPaths) {} - private Stream extractResource(JsonNode node) { - return extractFilePath(node) - .map(jsPath -> new FrontendResource(jsPath, extractCssPaths(node))) - .stream(); - } + static final class ViteManifest { - private Optional extractFilePath(JsonNode node) { - return Optional.ofNullable(node) - .map(n -> n.get("file")) - .map(this::toTargetPath); - } + private final JsonNode manifest; - private List extractCssPaths(JsonNode node) { - return Optional.ofNullable(node) - .map(n -> n.get("css")) - .filter(JsonNode::isArray) - .stream().flatMap(css -> StreamEx.of(css.elements())) - .map(this::toTargetPath) - .toList(); - } + ViteManifest(JsonNode manifest) { + this.manifest = manifest; + } + + Map frontendResources() { + return EntryStream.of(manifest.fields()) + .mapToValue(ViteManifestEntry::new) + .mapToKeyPartial((key, entry) -> entry.sourcePath()) + .mapToValuePartial((sourcePath, entry) -> entry.jsPath() + .map(jsPath -> toFrontendResource(jsPath, entry))) + .toImmutableMap(); + } + + private FrontendResource toFrontendResource(String jsPath, ViteManifestEntry entry) { + var cssPaths = collectCssPaths(entry).distinct().toList(); + return new FrontendResource(jsPath, cssPaths); + } + + private StreamEx collectCssPaths(ViteManifestEntry entry) { + return StreamEx.of(entry.imports() + .flatMap(key -> entry(key).stream()) + .flatMap(this::collectCssPaths)) + .append(entry.cssPaths()); + } - private String toTargetPath(JsonNode node) { - return Optional.ofNullable(node) - .map(f -> f.asText()) - .filter(f -> !f.isEmpty()) - .map(f -> TARGET_DIR + f) - .orElse(null); + private Optional entry(String key) { + return Optional.ofNullable(manifest.get(key)) + .map(json -> new ViteManifestEntry(key, json)); + } } - public record FrontendResource(String jsPath, List cssPaths) {} + record ViteManifestEntry(String key, JsonNode json) { + + private static final String FIELD_FILE = "file"; + private static final String FIELD_CSS = "css"; + private static final String FIELD_IMPORTS = "imports"; + + Optional sourcePath() { + return Optional.ofNullable(key) + .filter(k -> k.startsWith(MANIFEST_KEY_PREFIX)) + .map(k -> k.replaceFirst(MANIFEST_KEY_PREFIX, SOURCE_PREFIX)); + } + + Optional jsPath() { + return getNode(FIELD_FILE).map(this::toTargetPath); + } + + Stream cssPaths() { + return getArray(FIELD_CSS).map(this::toTargetPath).filter(Objects::nonNull); + } + + public Stream imports() { + return getArray(FIELD_IMPORTS).map(JsonNode::asText).filter(not(String::isEmpty)); + } + + private Optional getNode(String name) { + return Optional.ofNullable(json.get(name)); + } + + private Stream getArray(String fieldName) { + return getNode(fieldName) + .filter(JsonNode::isArray) + .stream() + .map(JsonNode::elements) + .flatMap(StreamEx::of); + } + + private String toTargetPath(JsonNode node) { + return Optional.ofNullable(node) + .map(JsonNode::asText) + .filter(not(String::isEmpty)) + .map(file -> TARGET_DIR + file) + .orElse(null); + } + } } diff --git a/src/test/java/com/celements/javascript/FrontendResourceResolverTest.java b/src/test/java/com/celements/javascript/FrontendResourceResolverTest.java index daae329d7..ce7b1d983 100644 --- a/src/test/java/com/celements/javascript/FrontendResourceResolverTest.java +++ b/src/test/java/com/celements/javascript/FrontendResourceResolverTest.java @@ -6,6 +6,7 @@ import java.util.Arrays; import java.util.Collections; +import java.util.List; import java.util.Optional; import org.junit.Before; @@ -69,6 +70,41 @@ public void test_get_missingCss() throws Exception { verify(resourceLoader); } + @Test + public void test_get_importedCss() throws Exception { + expect(resourceLoader.getResources("/resources/dist/.vite/manifest*.json")) + .andReturn(new Resource[] { manifestResource(""" + { + "_tailwind.BUC6EzWv.mjs": { + "file": "tailwind.BUC6EzWv.mjs", + "imports": [ + "_reactivity.esm-bundler.DLdBTi3W.mjs" + ], + "css": [ + "assets/tailwind-5_VRq81v.css" + ] + }, + "_reactivity.esm-bundler.DLdBTi3W.mjs": { + "file": "reactivity.esm-bundler.DLdBTi3W.mjs" + }, + "src/main/frontend/progon/vue-poc/main.ts": { + "file": "vue-poc.CPN5BtMj.mjs", + "imports": [ + "_tailwind.BUC6EzWv.mjs", + "_reactivity.esm-bundler.DLdBTi3W.mjs" + ] + } + } + """) }); + replay(resourceLoader); + + assertEquals(Optional.of(new FrontendResource("dist/vue-poc.CPN5BtMj.mjs", + List.of("dist/assets/tailwind-5_VRq81v.css"))), + resolver.get(":frontend/progon/vue-poc/main.ts")); + + verify(resourceLoader); + } + @Test public void test_get_notFound() throws Exception { expect(resourceLoader.getResources("/resources/dist/.vite/manifest*.json"))