This is an automated email from the ASF dual-hosted git repository. thiagohp pushed a commit to branch feature/es-module-support in repository https://gitbox.apache.org/repos/asf/tapestry-5.git
commit 7b55625592f161301e476b69237542c7dddf7017 Author: Thiago H. de Paula Figueiredo <thi...@arsmachina.com.br> AuthorDate: Tue Apr 1 15:31:17 2025 -0300 TAP5-2803: foundation work for ES module support --- 5_10_RELEASE_NOTES.md | 23 ++ .../java/org/apache/tapestry5/SymbolConstants.java | 14 +- .../internal/services/DocumentLinker.java | 16 ++ .../internal/services/DocumentLinkerImpl.java | 41 +++- .../internal/services/EsModuleInitsManager.java | 60 ++++++ .../services/PartialMarkupDocumentLinker.java | 17 +- .../services/ajax/JavaScriptSupportImpl.java | 125 +++++++++-- .../services/javascript/EsModuleManagerImpl.java | 238 +++++++++++++++++++++ .../apache/tapestry5/modules/JavaScriptModule.java | 4 + .../apache/tapestry5/modules/TapestryModule.java | 16 +- ...ialization.java => AbstractInitialization.java} | 20 +- .../javascript/EsModuleConfigurationCallback.java | 62 ++++++ .../javascript/EsModuleInitialization.java | 113 ++++++++++ .../services/javascript/EsModuleManager.java | 57 +++++ .../services/javascript/ImportPlacement.java | 37 ++++ .../services/javascript/Initialization.java | 2 +- .../services/javascript/JavaScriptSupport.java | 19 ++ .../javascript/ModuleConfigurationCallback.java | 2 +- .../tapestry5/integration/app1/EsModuleTests.java | 153 +++++++++++++ .../integration/app1/pages/EsModuleDemo.java | 86 ++++++++ .../tapestry5/integration/app1/pages/Index.java | 4 +- .../integration/app1/services/AppModule.java | 40 ++++ .../META-INF/assets/es-modules/foo/bar.js | 2 + .../assets/es-modules/placement/body-bottom.js | 2 + .../assets/es-modules/placement/body-top.js | 2 + .../META-INF/assets/es-modules/placement/head.js | 2 + .../META-INF/assets/es-modules/root-folder.js | 2 + .../META-INF/assets/es-modules/show-import-map.js | 2 + .../integration/app1/es-module-outside-metainf.js | 2 + .../integration/app1/pages/EsModuleDemo.tml | 30 +++ 30 files changed, 1145 insertions(+), 48 deletions(-) diff --git a/5_10_RELEASE_NOTES.md b/5_10_RELEASE_NOTES.md new file mode 100644 index 000000000..b99e05690 --- /dev/null +++ b/5_10_RELEASE_NOTES.md @@ -0,0 +1,23 @@ +Scratch pad for changes destined for the 5.10.0 release notes page. + +# Added configuration symbols + +* `tapestry.es-module-path-prefix` (`SymbolConstants.ES_MODULE_PATH_PREFIX`) + + +# Added methods + +* `JavaScriptSupport.importEsModule(String moduleName)` +* `JavaScriptSupport.addEsModuleConfigurationCallback(EsModuleConfigurationCallback callback)` + +# Added types + +* `org.apache.tapestry5.services.javascript.EsModuleInitialization` +* `org.apache.tapestry5.services.javascript.ImportPlacement` +* `org.apache.tapestry5.services.javascript.EsModuleConfigurationCallback` +* `org.apache.tapestry5.services.javascript.EsModuleManager` + +# Non-backward-compatible changes (but that probably won't cause problems) + + +# Overall notes diff --git a/tapestry-core/src/main/java/org/apache/tapestry5/SymbolConstants.java b/tapestry-core/src/main/java/org/apache/tapestry5/SymbolConstants.java index dc19b2756..51a304c2c 100644 --- a/tapestry-core/src/main/java/org/apache/tapestry5/SymbolConstants.java +++ b/tapestry-core/src/main/java/org/apache/tapestry5/SymbolConstants.java @@ -376,7 +376,7 @@ public class SymbolConstants /** - * Prefix used for all module resources. This may contain slashes, but should not being or end with one. + * Prefix used for all Require.js module resources. This may contain slashes, but should not being or end with one. * Tapestry will create two {@link org.apache.tapestry5.http.services.Dispatcher}s from this: one for normal * modules, the other for GZip compressed modules (by appending ".gz" to this value). * @@ -385,6 +385,18 @@ public class SymbolConstants * @since 5.4 */ public static final String MODULE_PATH_PREFIX = "tapestry.module-path-prefix"; + + /** + * Prefix used for automatically configured ES module resources. + * This may contain slashes, but should not being or end with one. + * + * The default is "es-modules". + * + * TODO remove + * + * @since 5.4 + */ + public static final String ES_MODULE_PATH_PREFIX = "tapestry.es-module-path-prefix"; /** * Identifies the context path of the application, as determined from {@link javax.servlet.ServletContext#getContextPath()}. diff --git a/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/DocumentLinker.java b/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/DocumentLinker.java index e63b014ab..9326f12f0 100644 --- a/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/DocumentLinker.java +++ b/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/DocumentLinker.java @@ -13,6 +13,8 @@ package org.apache.tapestry5.internal.services; import org.apache.tapestry5.json.JSONArray; +import org.apache.tapestry5.services.javascript.EsModuleConfigurationCallback; +import org.apache.tapestry5.services.javascript.EsModuleInitialization; import org.apache.tapestry5.services.javascript.InitializationPriority; import org.apache.tapestry5.services.javascript.ModuleConfigurationCallback; import org.apache.tapestry5.services.javascript.StylesheetLink; @@ -55,6 +57,14 @@ public interface DocumentLinker * @since 5.4 */ void addModuleConfigurationCallback(ModuleConfigurationCallback callback); + + /** + * Adds an ES module configuration callback for this request. + * + * @param callback a {@link EsModuleConfigurationCallback}. It cannot be null. + * @since 5.10.0 + */ + void addEsModuleConfigurationCallback(EsModuleConfigurationCallback callback); /** * Adds JavaScript code. The code is collected into a single block that is injected just before the close body tag @@ -88,4 +98,10 @@ public interface DocumentLinker String moduleName, String functionName, JSONArray arguments); + + /** + * Adds ES module initialization. + * @since 5.10.0 + */ + void addEsModuleInitialization(EsModuleInitialization initialization); } diff --git a/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/DocumentLinkerImpl.java b/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/DocumentLinkerImpl.java index fc0449818..8814e7faf 100644 --- a/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/DocumentLinkerImpl.java +++ b/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/DocumentLinkerImpl.java @@ -16,6 +16,9 @@ import org.apache.tapestry5.commons.util.CollectionFactory; import org.apache.tapestry5.dom.Document; import org.apache.tapestry5.dom.Element; import org.apache.tapestry5.json.JSONArray; +import org.apache.tapestry5.services.javascript.EsModuleConfigurationCallback; +import org.apache.tapestry5.services.javascript.EsModuleInitialization; +import org.apache.tapestry5.services.javascript.EsModuleManager; import org.apache.tapestry5.services.javascript.InitializationPriority; import org.apache.tapestry5.services.javascript.ModuleConfigurationCallback; import org.apache.tapestry5.services.javascript.ModuleManager; @@ -34,12 +37,18 @@ public class DocumentLinkerImpl implements DocumentLinker private final List<String> libraryURLs = CollectionFactory.newList(); private final ModuleInitsManager initsManager = new ModuleInitsManager(); + + private final EsModuleInitsManager esModulesinitsManager = new EsModuleInitsManager(); private final List<ModuleConfigurationCallback> moduleConfigurationCallbacks = CollectionFactory.newList(); - + + private final List<EsModuleConfigurationCallback> esModuleConfigurationCallbacks = CollectionFactory.newList(); + private final List<StylesheetLink> includedStylesheets = CollectionFactory.newList(); private final ModuleManager moduleManager; + + private final EsModuleManager esModuleManager; private final boolean omitGeneratorMetaTag, enablePageloadingMask; @@ -56,9 +65,11 @@ public class DocumentLinkerImpl implements DocumentLinker * @param enablePageloadingMask * @param tapestryVersion */ - public DocumentLinkerImpl(ModuleManager moduleManager, boolean omitGeneratorMetaTag, boolean enablePageloadingMask, String tapestryVersion) + public DocumentLinkerImpl(ModuleManager moduleManager, EsModuleManager esModuleManager, + boolean omitGeneratorMetaTag, boolean enablePageloadingMask, String tapestryVersion) { this.moduleManager = moduleManager; + this.esModuleManager = esModuleManager; this.omitGeneratorMetaTag = omitGeneratorMetaTag; this.enablePageloadingMask = enablePageloadingMask; @@ -85,6 +96,7 @@ public class DocumentLinkerImpl implements DocumentLinker hasScriptsOrInitializations = true; } + @SuppressWarnings("deprecation") public void addScript(InitializationPriority priority, String script) { addInitialization(priority, "t5/core/pageinit", "evalJavaScript", new JSONArray().put(script)); @@ -114,6 +126,7 @@ public class DocumentLinkerImpl implements DocumentLinker return; } + // TAP5-2200: Generating XML from pages and templates is not possible anymore // only add JavaScript and CSS if we're actually generating final String mimeType = document.getMimeType(); @@ -121,7 +134,7 @@ public class DocumentLinkerImpl implements DocumentLinker { return; } - + addStylesheetsToHead(root, includedStylesheets); // only add the generator meta only to html documents @@ -138,6 +151,14 @@ public class DocumentLinkerImpl implements DocumentLinker } addScriptElements(root); + + final List<EsModuleInitialization> esModuleInits = esModulesinitsManager.getInits(); + if (isHtmlRoot && !esModuleInits.isEmpty()) + { + esModuleManager.writeImportMap(root.find("head"), esModuleConfigurationCallbacks); + esModuleManager.writeImports(root, esModuleInits); + } + } private static Element addElementBefore(Element container, Element insertionPoint, String name, String... namesAndValues) @@ -305,5 +326,19 @@ public class DocumentLinkerImpl implements DocumentLinker assert callback != null; moduleConfigurationCallbacks.add(callback); } + + public void addEsModuleConfigurationCallback(EsModuleConfigurationCallback callback) + { + assert callback != null; + esModuleConfigurationCallbacks.add(callback); + } + @Override + public void addEsModuleInitialization(EsModuleInitialization initialization) + { + assert initialization != null; + esModulesinitsManager.add(initialization); + hasScriptsOrInitializations = true; + } + } diff --git a/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/EsModuleInitsManager.java b/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/EsModuleInitsManager.java new file mode 100644 index 000000000..4116679c9 --- /dev/null +++ b/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/EsModuleInitsManager.java @@ -0,0 +1,60 @@ +// Licensed 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. + +// +// Licensed 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.tapestry5.internal.services; + +import java.util.List; +import java.util.Set; + +import org.apache.tapestry5.commons.util.CollectionFactory; +import org.apache.tapestry5.services.javascript.EsModuleInitialization; + +public class EsModuleInitsManager +{ + private final Set<String> modules = CollectionFactory.newSet(); + + private final List<EsModuleInitialization> initializations = CollectionFactory.newList(); + + public void add(EsModuleInitialization initialization) + { + assert initialization != null; + + // We ignore a module being added again. + final String moduleName = initialization.getModuleId(); + if (!modules.contains(moduleName)) + { + initializations.add(initialization); + modules.add(moduleName); + } + } + + /** + * Returns all previously added inits. + */ + public List<EsModuleInitialization> getInits() + { + return initializations; + } +} diff --git a/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/PartialMarkupDocumentLinker.java b/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/PartialMarkupDocumentLinker.java index 9cde60c17..9ccf81629 100644 --- a/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/PartialMarkupDocumentLinker.java +++ b/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/PartialMarkupDocumentLinker.java @@ -17,6 +17,9 @@ package org.apache.tapestry5.internal.services; import org.apache.tapestry5.internal.InternalConstants; import org.apache.tapestry5.json.JSONArray; import org.apache.tapestry5.json.JSONObject; +import org.apache.tapestry5.services.javascript.EsModuleConfigurationCallback; +import org.apache.tapestry5.services.javascript.EsModuleInitialization; +import org.apache.tapestry5.services.javascript.EsModuleManager; import org.apache.tapestry5.services.javascript.InitializationPriority; import org.apache.tapestry5.services.javascript.ModuleConfigurationCallback; import org.apache.tapestry5.services.javascript.StylesheetLink; @@ -30,7 +33,7 @@ public class PartialMarkupDocumentLinker implements DocumentLinker private final JSONArray stylesheets = new JSONArray(); private final ModuleInitsManager initsManager = new ModuleInitsManager(); - + public void addCoreLibrary(String libraryURL) { notImplemented("addCoreLibrary"); @@ -72,6 +75,18 @@ public class PartialMarkupDocumentLinker implements DocumentLinker initsManager.addInitialization(priority, moduleName, functionName, arguments); } + @Override + public void addEsModuleConfigurationCallback(EsModuleConfigurationCallback callback) + { + notImplemented("moduleConfigurationCallback"); + } + + @Override + public void addEsModuleInitialization(EsModuleInitialization initialization) + { + notImplemented("addEsModuleInitialization"); + } + /** * Commits changes, adding one or more keys to the reply. * diff --git a/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/ajax/JavaScriptSupportImpl.java b/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/ajax/JavaScriptSupportImpl.java index 3a98ac74d..64fbadf78 100644 --- a/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/ajax/JavaScriptSupportImpl.java +++ b/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/ajax/JavaScriptSupportImpl.java @@ -12,6 +12,13 @@ package org.apache.tapestry5.internal.services.ajax; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Set; + import org.apache.tapestry5.Asset; import org.apache.tapestry5.BooleanHook; import org.apache.tapestry5.ComponentResources; @@ -26,9 +33,17 @@ import org.apache.tapestry5.ioc.internal.util.InternalUtils; import org.apache.tapestry5.ioc.util.IdAllocator; import org.apache.tapestry5.json.JSONArray; import org.apache.tapestry5.json.JSONObject; -import org.apache.tapestry5.services.javascript.*; - -import java.util.*; +import org.apache.tapestry5.services.javascript.AbstractInitialization; +import org.apache.tapestry5.services.javascript.EsModuleConfigurationCallback; +import org.apache.tapestry5.services.javascript.EsModuleInitialization; +import org.apache.tapestry5.services.javascript.ImportPlacement; +import org.apache.tapestry5.services.javascript.Initialization; +import org.apache.tapestry5.services.javascript.InitializationPriority; +import org.apache.tapestry5.services.javascript.JavaScriptStack; +import org.apache.tapestry5.services.javascript.JavaScriptStackSource; +import org.apache.tapestry5.services.javascript.JavaScriptSupport; +import org.apache.tapestry5.services.javascript.ModuleConfigurationCallback; +import org.apache.tapestry5.services.javascript.StylesheetLink; public class JavaScriptSupportImpl implements JavaScriptSupport { @@ -47,6 +62,10 @@ public class JavaScriptSupportImpl implements JavaScriptSupport private final List<StylesheetLink> stylesheetLinks = CollectionFactory.newList(); private final List<InitializationImpl> inits = CollectionFactory.newList(); + + private final List<EsModuleInitialization> esModuleInits = CollectionFactory.newList(); + + private final Set<String> esModulesImported = CollectionFactory.newSet(); private final JavaScriptStackSource javascriptStackSource; @@ -62,30 +81,46 @@ public class JavaScriptSupportImpl implements JavaScriptSupport private Map<String, String> libraryURLToStackName, moduleNameToStackName; - class InitializationImpl implements Initialization + abstract class BaseInitialization<T extends AbstractInitialization<?>> implements AbstractInitialization<T> { - InitializationPriority priority = InitializationPriority.NORMAL; - final String moduleName; String functionName; JSONArray arguments; - InitializationImpl(String moduleName) + BaseInitialization(String moduleName) { this.moduleName = moduleName; } - public Initialization invoke(String functionName) + public T invoke(String functionName) { assert InternalUtils.isNonBlank(functionName); this.functionName = functionName; - return this; + return (T) this; + } + + public void with(Object... arguments) + { + assert arguments != null; + + this.arguments = new JSONArray(arguments); } + } + + class InitializationImpl extends BaseInitialization<Initialization> implements Initialization + { + + InitializationPriority priority = InitializationPriority.NORMAL; + public InitializationImpl(String moduleName) + { + super(moduleName); + } + public Initialization priority(InitializationPriority priority) { assert priority != null; @@ -94,15 +129,54 @@ public class JavaScriptSupportImpl implements JavaScriptSupport return this; } + + } + + class EsModuleInitializationImpl extends BaseInitialization<EsModuleInitialization> implements EsModuleInitialization + { + + Map<String, String> attributes; + ImportPlacement placement = ImportPlacement.BODY_BOTTOM; + + EsModuleInitializationImpl(String moduleName) + { + super(moduleName); + } - public void with(Object... arguments) + public EsModuleInitialization withAttribute(String id, String value) { - assert arguments != null; + if (attributes == null) + { + attributes = CollectionFactory.newMap(); + } + attributes.put(id, value); + return this; + } - this.arguments = new JSONArray(arguments); + public EsModuleInitialization placement(ImportPlacement placement) + { + this.placement = placement; + return null; + } + + public String getModuleId() { + return moduleName; + } + + public Map<String, String> getAttributes() { + return attributes != null ? + Collections.unmodifiableMap(attributes) : + Collections.emptyMap(); } + + @Override + public ImportPlacement getPlacement() { + return placement; + } + } + public JavaScriptSupportImpl(DocumentLinker linker, JavaScriptStackSource javascriptStackSource, JavaScriptStackPathConstructor stackPathConstructor, BooleanHook suppressCoreStylesheetsHook) { @@ -150,6 +224,8 @@ public class JavaScriptSupportImpl implements JavaScriptSupport public void commit() { + + // TODO make no Require.js version of this if (focusFieldId != null) { require("t5/core/pageinit").invoke("focus").with(focusFieldId); @@ -176,6 +252,8 @@ public class JavaScriptSupportImpl implements JavaScriptSupport linker.addInitialization(element.priority, element.moduleName, element.functionName, element.arguments); } }); + + esModuleInits.stream().forEach(linker::addEsModuleInitialization); } public void addInitializerCall(InitializationPriority priority, String functionName, JSONObject parameter) @@ -462,4 +540,27 @@ public class JavaScriptSupportImpl implements JavaScriptSupport return init; } + @Override + public EsModuleInitialization importEsModule(String moduleName) + { + + assert InternalUtils.isNonBlank(moduleName); + + // TODO import core libraries (jQuery, Prototype/Scriptaculous/Underscore) + + EsModuleInitialization init = new EsModuleInitializationImpl(moduleName); + if (!esModulesImported.contains(moduleName)) + { + esModuleInits.add(init); + } + + return init; + } + + @Override + public void addEsModuleConfigurationCallback(EsModuleConfigurationCallback callback) + { + linker.addEsModuleConfigurationCallback(callback); + } + } diff --git a/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/javascript/EsModuleManagerImpl.java b/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/javascript/EsModuleManagerImpl.java new file mode 100644 index 000000000..dff737ee6 --- /dev/null +++ b/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/javascript/EsModuleManagerImpl.java @@ -0,0 +1,238 @@ +// Licensed 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.tapestry5.internal.services.javascript; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.apache.tapestry5.SymbolConstants; +import org.apache.tapestry5.commons.util.AvailableValues; +import org.apache.tapestry5.commons.util.CollectionFactory; +import org.apache.tapestry5.commons.util.UnknownValueException; +import org.apache.tapestry5.dom.Element; +import org.apache.tapestry5.http.TapestryHttpSymbolConstants; +import org.apache.tapestry5.internal.InternalConstants; +import org.apache.tapestry5.internal.services.assets.ResourceChangeTracker; +import org.apache.tapestry5.ioc.annotations.PostInjection; +import org.apache.tapestry5.ioc.annotations.Symbol; +import org.apache.tapestry5.ioc.services.ClasspathMatcher; +import org.apache.tapestry5.ioc.services.ClasspathScanner; +import org.apache.tapestry5.json.JSONObject; +import org.apache.tapestry5.services.AssetSource; +import org.apache.tapestry5.services.assets.StreamableResourceSource; +import org.apache.tapestry5.services.javascript.EsModuleConfigurationCallback; +import org.apache.tapestry5.services.javascript.EsModuleInitialization; +import org.apache.tapestry5.services.javascript.EsModuleManager; +import org.apache.tapestry5.services.javascript.ImportPlacement; + +public class EsModuleManagerImpl implements EsModuleManager +{ + + /** + * Name of the JSON object property containing the imports in an import map. + */ + public static final String IMPORTS_ATTRIBUTE = EsModuleConfigurationCallback.IMPORTS_ATTRIBUTE; + + private static final String CLASSPATH_ROOT = "META-INF/assets/es-modules/"; + + private final boolean compactJSON; + + private final boolean productionMode; + + private final Set<String> extensions; + + private final AssetSource assetSource; + + // Note: ConcurrentHashMap does not support null as a value, alas. We use classpathRoot as a null. + private final Map<String, String> cache = CollectionFactory.newConcurrentMap(); + + private final ClasspathScanner classpathScanner; + + private JSONObject importMap; + + private final List<EsModuleConfigurationCallback> globalCallbacks; + + public EsModuleManagerImpl( + List<EsModuleConfigurationCallback> globalCallbacks, + AssetSource assetSource, + StreamableResourceSource streamableResourceSource, + @Symbol(SymbolConstants.COMPACT_JSON) + boolean compactJSON, + @Symbol(TapestryHttpSymbolConstants.PRODUCTION_MODE) + boolean productionMode, + ClasspathScanner classpathScanner) + { + this.compactJSON = compactJSON; + this.assetSource = assetSource; + this.classpathScanner = classpathScanner; + this.globalCallbacks = globalCallbacks; + this.productionMode = productionMode; + importMap = new JSONObject(); + + extensions = CollectionFactory.newSet("js"); + extensions.addAll(streamableResourceSource.fileExtensionsForContentType(InternalConstants.JAVASCRIPT_CONTENT_TYPE)); + + createImportMap(); + + } + + private void createImportMap() + { + + JSONObject importMap = new JSONObject(); + JSONObject imports = importMap.in(IMPORTS_ATTRIBUTE); + + cache.clear(); + + loadBaseModuleList(imports); + + for (String name : cache.keySet()) + { + imports.put(name, cache.get(name)); + } + + this.importMap = executeCallbacks(importMap, globalCallbacks); + + for (String id : imports.keySet()) + { + cache.put(id, imports.getString(id)); + } + + } + + private void loadBaseModuleList(JSONObject imports) + { + ClasspathMatcher matcher = (packagePath, fileName) -> + extensions.stream().anyMatch(e -> fileName.endsWith(e)); + try + { + final Set<String> scan = classpathScanner.scan(CLASSPATH_ROOT, matcher); + for (String file : scan) + { + String id = file.replace(CLASSPATH_ROOT, ""); + id = id.substring(0, id.lastIndexOf('.')); + + imports.put(id, assetSource.getClasspathAsset(file).toClientURL()); + } + } catch (IOException e) + { + throw new RuntimeException(e); + } + } + + @PostInjection + public void setupInvalidation(ResourceChangeTracker tracker) + { + +// Live class reloading of ES modules (failing at the moment) + + // TODO make invalidations smarter (and work) + tracker.addInvalidationCallback(this::createImportMap); + } + + @Override + public void writeImportMap(Element head, List<EsModuleConfigurationCallback> moduleConfigurationCallbacks) { + + // Cloning the original import map JSON object + final JSONObject imports = ((JSONObject) importMap.get(EsModuleConfigurationCallback.IMPORTS_ATTRIBUTE)) + .copy(); + JSONObject newImportMap = new JSONObject( + EsModuleConfigurationCallback.IMPORTS_ATTRIBUTE, imports); + + newImportMap = executeCallbacks(newImportMap, moduleConfigurationCallbacks); + + head.element("script") + .attribute("type", "importmap") + .text(newImportMap.toString(compactJSON)); + } + + @Override + public void writeImports(Element root, List<EsModuleInitialization> inits) + { + Element script; + Element body = null; + Element head = null; + ImportPlacement placement; + for (EsModuleInitialization init : inits) + { + + final String moduleId = init.getModuleId(); + // Making sure the user doesn't shoot heir own foot + final String url = cache.get(moduleId); + if (url == null) + { + throw new UnknownValueException("ES module not found: " + moduleId, + new AvailableValues("String", cache)); + } + + placement = init.getPlacement(); + if (placement.equals(ImportPlacement.HEAD)) + { + if (head == null) + { + head = root.find("head"); + } + script = head.element("script"); + } + else { + if (body == null) + { + body = root.find("body"); + } + if (placement.equals(ImportPlacement.BODY_BOTTOM)) { + script = body.element("script"); + } + else if (placement.equals(ImportPlacement.BODY_TOP)) + { + script = body.elementAt(0, "script"); + } + else + { + throw new IllegalArgumentException("Unknown import placement: " + placement); + } + } + + final Map<String, String> attributes = init.getAttributes(); + for (String name : attributes.keySet()) + { + script.attribute(name, attributes.get(name)); + } + + script.attribute("src", url); + script.attribute("type", "module"); + + if (!productionMode) + { + script.attribute("data-module-id", moduleId); + final Element log = script.element("script", "type", "text/javascript"); + log.text(String.format("console.debug('Imported ES module %s');", moduleId)); + log.moveBefore(script); + } + + } + + } + + private JSONObject executeCallbacks(JSONObject importMap, List<EsModuleConfigurationCallback> callbacks) + { + for (EsModuleConfigurationCallback callback : callbacks) + { + callback.configure(importMap); + } + + return importMap; + } + +} diff --git a/tapestry-core/src/main/java/org/apache/tapestry5/modules/JavaScriptModule.java b/tapestry-core/src/main/java/org/apache/tapestry5/modules/JavaScriptModule.java index 955d964fe..b452932f5 100644 --- a/tapestry-core/src/main/java/org/apache/tapestry5/modules/JavaScriptModule.java +++ b/tapestry-core/src/main/java/org/apache/tapestry5/modules/JavaScriptModule.java @@ -32,6 +32,7 @@ import org.apache.tapestry5.internal.services.ajax.JavaScriptSupportImpl; import org.apache.tapestry5.internal.services.assets.ResourceChangeTracker; import org.apache.tapestry5.internal.services.javascript.AddBrowserCompatibilityStyles; import org.apache.tapestry5.internal.services.javascript.ConfigureHTMLElementFilter; +import org.apache.tapestry5.internal.services.javascript.EsModuleManagerImpl; import org.apache.tapestry5.internal.services.javascript.Internal; import org.apache.tapestry5.internal.services.javascript.JavaScriptStackPathConstructor; import org.apache.tapestry5.internal.services.javascript.JavaScriptStackSourceImpl; @@ -60,6 +61,7 @@ import org.apache.tapestry5.services.PathConstructor; import org.apache.tapestry5.services.compatibility.Compatibility; import org.apache.tapestry5.services.compatibility.Trait; import org.apache.tapestry5.services.javascript.AMDWrapper; +import org.apache.tapestry5.services.javascript.EsModuleManager; import org.apache.tapestry5.services.javascript.ExtensibleJavaScriptStack; import org.apache.tapestry5.services.javascript.JavaScriptModuleConfiguration; import org.apache.tapestry5.services.javascript.JavaScriptStack; @@ -92,6 +94,7 @@ public class JavaScriptModule public static void bind(ServiceBinder binder) { binder.bind(ModuleManager.class, ModuleManagerImpl.class); + binder.bind(EsModuleManager.class, EsModuleManagerImpl.class); binder.bind(JavaScriptStackSource.class, JavaScriptStackSourceImpl.class); binder.bind(JavaScriptStack.class, ExtensibleJavaScriptStack.class).withMarker(Core.class).withId("CoreJavaScriptStack"); binder.bind(JavaScriptStack.class, ExtensibleJavaScriptStack.class).withMarker(Internal.class).withId("InternalJavaScriptStack"); @@ -490,6 +493,7 @@ public class JavaScriptModule { configuration.add(SymbolConstants.JAVASCRIPT_INFRASTRUCTURE_PROVIDER, "prototype"); configuration.add(SymbolConstants.MODULE_PATH_PREFIX, "modules"); + configuration.add(SymbolConstants.ES_MODULE_PATH_PREFIX, "es-modules"); } @Contribute(ModuleManager.class) diff --git a/tapestry-core/src/main/java/org/apache/tapestry5/modules/TapestryModule.java b/tapestry-core/src/main/java/org/apache/tapestry5/modules/TapestryModule.java index e3c47e86f..7f339ee65 100644 --- a/tapestry-core/src/main/java/org/apache/tapestry5/modules/TapestryModule.java +++ b/tapestry-core/src/main/java/org/apache/tapestry5/modules/TapestryModule.java @@ -20,19 +20,15 @@ import java.math.BigInteger; import java.net.URL; import java.text.DateFormat; import java.text.SimpleDateFormat; -import java.util.Arrays; import java.util.Calendar; import java.util.Collection; -import java.util.Collections; import java.util.Date; -import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Properties; import java.util.Set; import java.util.regex.Pattern; -import java.util.stream.Collectors; import org.apache.tapestry5.Asset; import org.apache.tapestry5.BindingConstants; @@ -108,12 +104,7 @@ import org.apache.tapestry5.commons.services.TypeCoercer; import org.apache.tapestry5.commons.util.AvailableValues; import org.apache.tapestry5.commons.util.CollectionFactory; import org.apache.tapestry5.commons.util.StrategyRegistry; -import org.apache.tapestry5.corelib.components.BeanEditor; -import org.apache.tapestry5.corelib.components.PropertyDisplay; -import org.apache.tapestry5.corelib.components.PropertyEditor; import org.apache.tapestry5.corelib.data.SecureOption; -import org.apache.tapestry5.corelib.pages.PropertyDisplayBlocks; -import org.apache.tapestry5.corelib.pages.PropertyEditBlocks; import org.apache.tapestry5.grid.GridConstants; import org.apache.tapestry5.grid.GridDataSource; import org.apache.tapestry5.http.Link; @@ -174,7 +165,6 @@ import org.apache.tapestry5.internal.services.*; import org.apache.tapestry5.internal.services.ajax.AjaxFormUpdateFilter; import org.apache.tapestry5.internal.services.ajax.AjaxResponseRendererImpl; import org.apache.tapestry5.internal.services.ajax.MultiZoneUpdateEventResultProcessor; -import org.apache.tapestry5.internal.services.assets.ResourceChangeTracker; import org.apache.tapestry5.internal.services.exceptions.ExceptionReportWriterImpl; import org.apache.tapestry5.internal.services.exceptions.ExceptionReporterImpl; import org.apache.tapestry5.internal.services.linktransform.LinkTransformerImpl; @@ -365,6 +355,7 @@ import org.apache.tapestry5.services.ValueLabelProvider; import org.apache.tapestry5.services.ajax.AjaxResponseRenderer; import org.apache.tapestry5.services.dynamic.DynamicTemplate; import org.apache.tapestry5.services.dynamic.DynamicTemplateParser; +import org.apache.tapestry5.services.javascript.EsModuleManager; import org.apache.tapestry5.services.javascript.JavaScriptSupport; import org.apache.tapestry5.services.javascript.ModuleManager; import org.apache.tapestry5.services.linktransform.ComponentEventLinkTransformer; @@ -376,7 +367,6 @@ import org.apache.tapestry5.services.meta.FixedExtractor; import org.apache.tapestry5.services.meta.MetaDataExtractor; import org.apache.tapestry5.services.meta.MetaWorker; import org.apache.tapestry5.services.pageload.PageClassLoaderContextManager; -import org.apache.tapestry5.services.pageload.PageClassLoaderContextManagerImpl; import org.apache.tapestry5.services.pageload.PreloaderMode; import org.apache.tapestry5.services.rest.MappedEntityManager; import org.apache.tapestry5.services.rest.OpenApiDescriptionGenerator; @@ -1803,6 +1793,8 @@ public final class TapestryModule public void contributeMarkupRenderer(OrderedConfiguration<MarkupRendererFilter> configuration, final ModuleManager moduleManager, + + final EsModuleManager esModuleManager, @Symbol(SymbolConstants.OMIT_GENERATOR_META) final boolean omitGeneratorMeta, @@ -1825,7 +1817,7 @@ public final class TapestryModule { public void renderMarkup(MarkupWriter writer, MarkupRenderer renderer) { - DocumentLinkerImpl linker = new DocumentLinkerImpl(moduleManager, omitGeneratorMeta, enablePageloadingMask, tapestryVersion); + DocumentLinkerImpl linker = new DocumentLinkerImpl(moduleManager, esModuleManager, omitGeneratorMeta, enablePageloadingMask, tapestryVersion); environment.push(DocumentLinker.class, linker); diff --git a/tapestry-core/src/main/java/org/apache/tapestry5/services/javascript/Initialization.java b/tapestry-core/src/main/java/org/apache/tapestry5/services/javascript/AbstractInitialization.java similarity index 72% copy from tapestry-core/src/main/java/org/apache/tapestry5/services/javascript/Initialization.java copy to tapestry-core/src/main/java/org/apache/tapestry5/services/javascript/AbstractInitialization.java index a633722fc..6aa58d866 100644 --- a/tapestry-core/src/main/java/org/apache/tapestry5/services/javascript/Initialization.java +++ b/tapestry-core/src/main/java/org/apache/tapestry5/services/javascript/AbstractInitialization.java @@ -13,12 +13,11 @@ package org.apache.tapestry5.services.javascript; /** - * Provided by {@link JavaScriptSupport#require(String)} to allow additional, optional, details of the module-based page initialization - * to be configured. + * Superinterface with the parts shared by {@linkplain Initialization} and {@linkplain EsModuleInitialization}. * - * @since 5.4 + * @since 5.10.0 */ -public interface Initialization +public interface AbstractInitialization<T extends AbstractInitialization<?>> { /** @@ -29,18 +28,7 @@ public interface Initialization * name of a function exported by the module. * @return this Initialization, for further configuration */ - Initialization invoke(String functionName); - - /** - * Changes the initialization priority of the initialization from its default, {@link InitializationPriority#NORMAL}. - * - * Note: it is possible that this method may be removed before release 5.4 is final. - * - * @param priority - * new priority - * @return this Initialization, for further configuration - */ - Initialization priority(InitializationPriority priority); + T invoke(String functionName); /** * Specifies the arguments to be passed to the function. Often, just a single {@link org.apache.tapestry5.json.JSONObject} diff --git a/tapestry-core/src/main/java/org/apache/tapestry5/services/javascript/EsModuleConfigurationCallback.java b/tapestry-core/src/main/java/org/apache/tapestry5/services/javascript/EsModuleConfigurationCallback.java new file mode 100644 index 000000000..12182bae8 --- /dev/null +++ b/tapestry-core/src/main/java/org/apache/tapestry5/services/javascript/EsModuleConfigurationCallback.java @@ -0,0 +1,62 @@ +// Licensed 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.tapestry5.services.javascript; + +import org.apache.tapestry5.json.JSONObject; + +/** + * Interface used to to change the JSON configuration object which will be used in the + * import map to be generated by the {@linkplain ModuleManager} service at 2 different times: + * <ol> + * <li> + * During webapp, based on on contributions to {@linkplain EsModuleManager}. + * These are considered global callbacks, since they affect the base + * import map used in all requests. + * </li> + * <li> + * During page rendering, allowing components, pages and base components + * to further customize the base import map by for that specific request in + * a per-request basis by using the + * {@linkplain JavaScriptSupport#addModuleConfigurationCallback(EsModuleConfigurationCallback)} method. + * </li> + * </ol> + * + * @see JavaScriptSupport#addEsModuleConfigurationCallback(EsModuleConfigurationCallback) + * @since 5.10.0 + */ +public interface EsModuleConfigurationCallback +{ + /** + * Name of the JSON object property containing the imports in an import map. + */ + String IMPORTS_ATTRIBUTE = "imports"; + + /** + * Receives the current configuration, which can be copied or returned, or, more typically, modified and returned. + * + * @param configuration + * a {@link JSONObject} containing the current configuration. + */ + void configure(JSONObject configuration); + + /** + * Utility method to set or override a module and its URL. + * @param object the {@link JSONObject}. + * @param id the module id. + * @param url the module URL. + */ + public static void setImport(JSONObject object, String id, String url) + { + object.in(IMPORTS_ATTRIBUTE).put(id, url); + } +} diff --git a/tapestry-core/src/main/java/org/apache/tapestry5/services/javascript/EsModuleInitialization.java b/tapestry-core/src/main/java/org/apache/tapestry5/services/javascript/EsModuleInitialization.java new file mode 100644 index 000000000..5894bdab3 --- /dev/null +++ b/tapestry-core/src/main/java/org/apache/tapestry5/services/javascript/EsModuleInitialization.java @@ -0,0 +1,113 @@ +// Licensed 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.tapestry5.services.javascript; + +import java.util.Map; + +/** + * Provided by {@link JavaScriptSupport#importEsModule(String)} to allow additional, optional, + * details of the ES module import. + * + * @since 5.10.0 + */ +public interface EsModuleInitialization extends AbstractInitialization<EsModuleInitialization> +{ + + /** + * Defines an attribute name and value to be added to the corresponding + * <code><script></code> element. If the attribute was already set, + * its value will be overwritten. + * + * @param name The attribute name. It cannot be null nor empty. + * @param value The attribute value. It cannot be null nor empty. + * @return this <code>EsModuleInitialization</code> for further configuration. + */ + EsModuleInitialization withAttribute(String name, String value); + + /** + * Same as <code>withAttribute(name, name)</code>. Useful for attributes + * without values, such as <code>defer</code> and <code>async</code>. + * + * @param name The attribute name. It cannot be null nor empty. + * @return this <code>EsModuleInitialization</code> for further configuration. + */ + default EsModuleInitialization withAttribute(String name) + { + return withAttribute(name, name); + } + + /** + * Utility method for adding the <code>defer</code> attribute. + * @return this <code>EsModuleInitialization</code> for further configuration. + */ + default EsModuleInitialization withDefer() + { + return withAttribute("defer"); + } + + /** + * Utility method for adding the <code>async</code> attribute. + * @return this <code>EsModuleInitialization</code> for further configuration. + */ + default EsModuleInitialization withAsync() + { + return withAttribute("async"); + } + + /** + * Defines where this import should be done. + * @param placement an {@linkplain ImportPlacement} instance. It cannot be null. + * @return this <code>EsModuleInitialization</code> for further configuration. + */ + EsModuleInitialization placement(ImportPlacement placement); + + /** + * Specifies the name of an module exported function to invoke. + * If this method is not invoked, then the module is expected to export + * just a single function (which may, or may not, take {@linkplain #with(Object...) parameters}). + * + * @param functionName + * name of a function exported by the module. + * @return this <code>EsModuleInitialization</code>, for further configuration. + */ + EsModuleInitialization invoke(String functionName); + + /** + * Specifies the arguments to be passed to the function. Often, just a single {@link org.apache.tapestry5.json.JSONObject} + * is passed. When multiple Initializations exist with the same function name (or no function name), and no arguments, + * they are coalesced into a single EsModuleInitialization: it is assumed that an initialization with no parameters needs to + * only be invoked once. + * + * @param arguments + * any number of values. Each value may be one of: null, String, Boolean, Number, + * {@link org.apache.tapestry5.json.JSONObject}, {@link org.apache.tapestry5.json.JSONArray}, or + * {@link org.apache.tapestry5.json.JSONLiteral}. + */ + void with(Object... arguments); + + /** + * Returns the module id. + */ + String getModuleId(); + + /** + * Returns an unmodifiable view of the attributes. + */ + Map<String, String> getAttributes(); + + /** + * Returns the import placement. + */ + ImportPlacement getPlacement(); + +} diff --git a/tapestry-core/src/main/java/org/apache/tapestry5/services/javascript/EsModuleManager.java b/tapestry-core/src/main/java/org/apache/tapestry5/services/javascript/EsModuleManager.java new file mode 100644 index 000000000..262ae4c3b --- /dev/null +++ b/tapestry-core/src/main/java/org/apache/tapestry5/services/javascript/EsModuleManager.java @@ -0,0 +1,57 @@ +// Licensed 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.tapestry5.services.javascript; + +import java.util.List; + +import org.apache.tapestry5.dom.Element; +import org.apache.tapestry5.ioc.annotations.UsesOrderedConfiguration; + +/** + * Responsible for managing access to the ES modules. This service's distributed + * configuration allows the initial import map JSON object to be customized + * in a webapp-wide basis. + * + * @since 5.10.0 + * @see EsModuleConfigurationCallback + */ +@UsesOrderedConfiguration(EsModuleConfigurationCallback.class) +public interface EsModuleManager +{ + /** + * Invoked by the internal {@link org.apache.tapestry5.internal.services.DocumentLinker} service to + * write the import map into the page. + * + * @param body + * {@code <body>} element of the page, to which new {@code <script>} element(s) may be added. + * @param moduleConfigurationCallbacks + * a list of {@link org.apache.tapestry5.services.javascript.ModuleConfigurationCallback}s, which + * is used to customize the configuration before it is written. + */ + void writeImportMap(Element head, + List<EsModuleConfigurationCallback> moduleConfigurationCallbacks); + + /** + * Invoked by the internal {@link org.apache.tapestry5.internal.services.DocumentLinker} service to write the + * ES module imports (as per {@link JavaScriptSupport#importEsModule(String)} into the page. + * this occurs after the ES module infrastructure + * has been written into the page, along with the core libraries. + * + * @param root + * {@code <root>} element of the page. + * @param inits + * specify initialization on the page, based on loading modules, extacting functions from modules, and invoking those functions + */ + void writeImports(Element root, List<EsModuleInitialization> inits); + +} diff --git a/tapestry-core/src/main/java/org/apache/tapestry5/services/javascript/ImportPlacement.java b/tapestry-core/src/main/java/org/apache/tapestry5/services/javascript/ImportPlacement.java new file mode 100644 index 000000000..c53585ffd --- /dev/null +++ b/tapestry-core/src/main/java/org/apache/tapestry5/services/javascript/ImportPlacement.java @@ -0,0 +1,37 @@ +// Licensed 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.tapestry5.services.javascript; + +/** + * Enumeration class defining the possible placements of JavaScript imports. + * + * @since 5.10.0 + */ +public enum ImportPlacement +{ + /** + * Inside the <code><head></code> HTML element. + */ + HEAD, + + /** + * Towards the top of the <code><body></code> HTML element. + */ + BODY_TOP, + + /** + * Towards the bottom of the <code><body></code> HTML element. + */ + BODY_BOTTOM + +} diff --git a/tapestry-core/src/main/java/org/apache/tapestry5/services/javascript/Initialization.java b/tapestry-core/src/main/java/org/apache/tapestry5/services/javascript/Initialization.java index a633722fc..6f7e414e5 100644 --- a/tapestry-core/src/main/java/org/apache/tapestry5/services/javascript/Initialization.java +++ b/tapestry-core/src/main/java/org/apache/tapestry5/services/javascript/Initialization.java @@ -18,7 +18,7 @@ package org.apache.tapestry5.services.javascript; * * @since 5.4 */ -public interface Initialization +public interface Initialization extends AbstractInitialization<Initialization> { /** diff --git a/tapestry-core/src/main/java/org/apache/tapestry5/services/javascript/JavaScriptSupport.java b/tapestry-core/src/main/java/org/apache/tapestry5/services/javascript/JavaScriptSupport.java index 526144da1..7624497b0 100644 --- a/tapestry-core/src/main/java/org/apache/tapestry5/services/javascript/JavaScriptSupport.java +++ b/tapestry-core/src/main/java/org/apache/tapestry5/services/javascript/JavaScriptSupport.java @@ -273,4 +273,23 @@ public interface JavaScriptSupport */ void addModuleConfigurationCallback(ModuleConfigurationCallback callback); + /** + * Imports an <a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules">ES module</a>. + * @param moduleId the id of the module to import. + * @return an <code>EsModuleInitialization</code> instance to optionally configure + * the import and add a call an exported function, with or without parameters. + * @since 5.10.0 + */ + EsModuleInitialization importEsModule(String moduleId); + + /** + * Adds an ES module configuration callback for this request. + * + * @param callback + * a {@link ModuleConfigurationCallback}. It cannot be null. + * @see DocumentLinker#addModuleConfigurationCallback(ModuleConfigurationCallback) + * @since 5.10.0 + */ + void addEsModuleConfigurationCallback(EsModuleConfigurationCallback callback); + } diff --git a/tapestry-core/src/main/java/org/apache/tapestry5/services/javascript/ModuleConfigurationCallback.java b/tapestry-core/src/main/java/org/apache/tapestry5/services/javascript/ModuleConfigurationCallback.java index 79c5d00f8..790aaef2d 100644 --- a/tapestry-core/src/main/java/org/apache/tapestry5/services/javascript/ModuleConfigurationCallback.java +++ b/tapestry-core/src/main/java/org/apache/tapestry5/services/javascript/ModuleConfigurationCallback.java @@ -33,7 +33,7 @@ import org.apache.tapestry5.json.JSONObject; public interface ModuleConfigurationCallback { /** - * Receives the current configuration, which can be copied or returned, or (more typically) modified and returned. + * Receives the current configuration, which can be copied or returned, or, more typically, modified and returned. * * @param configuration * a {@link JSONObject} containing the current configuration. diff --git a/tapestry-core/src/test/java/org/apache/tapestry5/integration/app1/EsModuleTests.java b/tapestry-core/src/test/java/org/apache/tapestry5/integration/app1/EsModuleTests.java new file mode 100644 index 000000000..54be632ac --- /dev/null +++ b/tapestry-core/src/test/java/org/apache/tapestry5/integration/app1/EsModuleTests.java @@ -0,0 +1,153 @@ +// Licensed 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.tapestry5.integration.app1; + +import static org.apache.tapestry5.integration.app1.services.AppModule.NON_OVERRIDDEN_ES_MODULE_ID; +import static org.apache.tapestry5.integration.app1.services.AppModule.NON_OVERRIDDEN_ES_MODULE_URL; +import static org.apache.tapestry5.integration.app1.services.AppModule.OVERRIDDEN_ES_MODULE_ID; +import static org.apache.tapestry5.integration.app1.services.AppModule.OVERRIDDEN_ES_MODULE_NEW_URL; + +import org.apache.tapestry5.integration.app1.pages.EsModuleDemo; +import org.apache.tapestry5.ioc.annotations.Inject; +import org.apache.tapestry5.json.JSONObject; +import org.apache.tapestry5.services.AssetSource; +import org.apache.tapestry5.services.javascript.EsModuleConfigurationCallback; +import org.apache.tapestry5.services.javascript.EsModuleManager; +import org.apache.tapestry5.services.javascript.JavaScriptSupport; +import org.testng.annotations.Test; + +/** + * ES module tests. + */ +public class EsModuleTests extends App1TestCase +{ + private static final String PAGE_NAME = "ES Module Demo"; + + private static final String REQUEST_CALLBACK_SWITCHER = "css=.switch"; + + @Inject + private AssetSource assetSource; + + /** + * Tests whether ES modules placed in /META-INF/es-modules are automatically + * added to import maps. + */ + @Test + public void automatic_modules() + { + openLinks(PAGE_NAME); + JSONObject importMap = getImportMap(); + assertModuleUrlSuffix("foo/bar", "/es-modules/foo/bar.js", importMap); + assertModuleUrlSuffix("root-folder", "/es-modules/root-folder.js", importMap); + } + + /** + * Tests whether ES modules added or overriden through global callbacks + * (i.e. ones contributed to {@link EsModuleManager} configuration) + * are being actually included in the generated import map. + */ + @Test + public void modules_added_by_global_callbacks() + { + openLinks(PAGE_NAME); + JSONObject importMap = getImportMap(); + assertModulesDefinedByGlobalCallbacks(importMap); + } + + /** + * Tests whether ES modules added or overriden through request callbacks + * (i.e. ones added through {@link JavaScriptSupport#addEsModuleConfigurationCallback(EsModuleConfigurationCallback)}) + * are being actually included in the generated import map. + * @throws InterruptedException + */ + @Test + public void modules_added_by_request_callbacks() + { + openLinks(PAGE_NAME); + + // With import map changed by request callbacks. + clickAndWait(REQUEST_CALLBACK_SWITCHER); + JSONObject importMap = getImportMap(); + assertModuleUrl(NON_OVERRIDDEN_ES_MODULE_ID, NON_OVERRIDDEN_ES_MODULE_URL, importMap); + assertModuleUrl(OVERRIDDEN_ES_MODULE_ID, EsModuleDemo.REQUEST_OVERRIDEN_MODULE_URL, importMap); + + // Now without import map changed by request callbacks, so we can test + // the global import map wasn't affected. + clickAndWait(REQUEST_CALLBACK_SWITCHER); + importMap = getImportMap(); + assertModulesDefinedByGlobalCallbacks(importMap); + + } + + /** + * Tests {@link JavaScriptSupport#importEsModule(String)}. + */ + @Test + public void javascript_support_importEsModule() throws InterruptedException + { + + openLinks(PAGE_NAME); + + // Module imported with specified attributes. + assertTrue(isElementPresent("//script[@type='module'][contains(@src, 'foo/bar.js')][@defer='defer'][@async='async'][@something='else'][@foo='foo']")); + + // Module imported with no placement (default body bottom) or + // BODY_BOTTOM should be after the last <div> in this webapp's template + assertTrue(isElementPresent("//body/div[last()][following-sibling::script[@type='module'][contains(@src, '/placement/body-bottom.js')]]")); + + // Module imported with placement BODY_TOP should be before + // the last <div> in this webapp's template (the first one comes from JS). + assertTrue(isElementPresent("//body/div[@role='navigation'][preceding-sibling::script[@type='module'][contains(@src, '/placement/body-top.js')]]")); + + // Module imported with placement HEAD + assertTrue(isElementPresent("//head/script[@type='module'][contains(@src, '/placement/head.js')]")); + + // Checking results of running the modules, not just their inclusion in HTML + assertEquals(getText("message"), "ES module foo/bar imported correctly!"); + assertEquals(getText("head-message"), "ES module imported correctly (<head>)!"); + assertEquals(getText("body-top-message"), "ES module imported correctly (<body> top)!"); + assertEquals(getText("body-bottom-message"), "ES module imported correctly (<body> bottom)!"); + assertEquals(getText("root-folder-message"), "ES module imported correctly from the root folder!"); + assertEquals(getText("outside-metainf-message"), "ES module correctly imported from outside /META-INF/assets/es-modules!"); + + } + + private void assertModulesDefinedByGlobalCallbacks(JSONObject importMap) { + assertModuleUrl(NON_OVERRIDDEN_ES_MODULE_ID, NON_OVERRIDDEN_ES_MODULE_URL, importMap); + assertModuleUrl(OVERRIDDEN_ES_MODULE_ID, OVERRIDDEN_ES_MODULE_NEW_URL, importMap); + } + + private void assertModuleUrlSuffix(String id, String urlSuffix, JSONObject importMap) + { + final JSONObject imports = (JSONObject) importMap.get(EsModuleConfigurationCallback.IMPORTS_ATTRIBUTE); + final String url = imports.getString(id); + + assertNotNull(url, String.format("Module %s not found in import map\n%s", id, importMap.toString(false))); + assertTrue(url.endsWith(urlSuffix), String.format("Unexpected URL %s for module %s (expected %s suffix)", url, id, urlSuffix)); + } + + private void assertModuleUrl(String id, String urlSuffix, JSONObject importMap) + { + final JSONObject imports = (JSONObject) importMap.get(EsModuleConfigurationCallback.IMPORTS_ATTRIBUTE); + final String url = imports.getString(id); + + assertNotNull(url, String.format("Module %s not found in import map\n%s", id, importMap.toString(false))); + assertEquals(url, urlSuffix, String.format("Unexpected URL %s for module %s (expected %s suffix)", url, id, urlSuffix)); + } + + private JSONObject getImportMap() + { + return new JSONObject(getText("import-map-listing")); + } + +} diff --git a/tapestry-core/src/test/java/org/apache/tapestry5/integration/app1/pages/EsModuleDemo.java b/tapestry-core/src/test/java/org/apache/tapestry5/integration/app1/pages/EsModuleDemo.java new file mode 100644 index 000000000..e53bd964b --- /dev/null +++ b/tapestry-core/src/test/java/org/apache/tapestry5/integration/app1/pages/EsModuleDemo.java @@ -0,0 +1,86 @@ +// Licensed 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.tapestry5.integration.app1.pages; + +import org.apache.tapestry5.annotations.Property; +import org.apache.tapestry5.annotations.SetupRender; +import org.apache.tapestry5.integration.app1.services.AppModule; +import org.apache.tapestry5.ioc.annotations.Inject; +import org.apache.tapestry5.services.javascript.EsModuleConfigurationCallback; +import org.apache.tapestry5.services.javascript.ImportPlacement; +import org.apache.tapestry5.services.javascript.JavaScriptSupport; + +public class EsModuleDemo +{ + public static final String REQUEST_OVERRIDEN_MODULE_URL = "/overridenAgainURL"; + + @Inject + private JavaScriptSupport javaScriptSupport; + + @Property + Boolean overrideEsModuleImportAgain; + + @SetupRender + void importEsModule() + { + // Checking each module is only imported once. + javaScriptSupport.importEsModule("foo/bar") + .withDefer() + .withAsync() + .withAttribute("foo") + .withAttribute("something", "else"); + javaScriptSupport.importEsModule("foo/bar"); + + javaScriptSupport.importEsModule("placement/body-bottom") + .placement(ImportPlacement.BODY_BOTTOM); + javaScriptSupport.importEsModule("placement/body-top") + .placement(ImportPlacement.BODY_TOP); + javaScriptSupport.importEsModule("placement/head") + .placement(ImportPlacement.HEAD); + javaScriptSupport.importEsModule("root-folder"); + javaScriptSupport.importEsModule("outside-metainf"); + javaScriptSupport.importEsModule("show-import-map"); + + if (overrideEsModuleImportAgain != null && overrideEsModuleImportAgain) + { + javaScriptSupport.addEsModuleConfigurationCallback( + o -> EsModuleConfigurationCallback.setImport(o, + AppModule.OVERRIDDEN_ES_MODULE_ID, REQUEST_OVERRIDEN_MODULE_URL)); + } + + } + + void onActivate(boolean overrideEsModuleImportAgain) + { + this.overrideEsModuleImportAgain = overrideEsModuleImportAgain; + } + + Object onEnableOverride() + { + overrideEsModuleImportAgain = true; + return this; + } + + void onDisableOverride() + { + overrideEsModuleImportAgain = false; + } + + Object[] onPassivate() + { + return overrideEsModuleImportAgain != null && overrideEsModuleImportAgain + ? new Object[] { overrideEsModuleImportAgain } + : null; + } + +} diff --git a/tapestry-core/src/test/java/org/apache/tapestry5/integration/app1/pages/Index.java b/tapestry-core/src/test/java/org/apache/tapestry5/integration/app1/pages/Index.java index 025b7eb07..cbbc34c5d 100644 --- a/tapestry-core/src/test/java/org/apache/tapestry5/integration/app1/pages/Index.java +++ b/tapestry-core/src/test/java/org/apache/tapestry5/integration/app1/pages/Index.java @@ -630,7 +630,9 @@ public class Index new Item("RecursiveDemo","Recursive Demo","Recursive component example"), - new Item("SelfRecursiveDemo", "Self-Recursive Demo", "check for handling of self-recursive components") + new Item("SelfRecursiveDemo", "Self-Recursive Demo", "check for handling of self-recursive components"), + + new Item("EsModuleDemo", "ES Module Demo", "tests and demonstrations for the ES module support") ); static diff --git a/tapestry-core/src/test/java/org/apache/tapestry5/integration/app1/services/AppModule.java b/tapestry-core/src/test/java/org/apache/tapestry5/integration/app1/services/AppModule.java index 6801928b8..e8b91f4d0 100644 --- a/tapestry-core/src/test/java/org/apache/tapestry5/integration/app1/services/AppModule.java +++ b/tapestry-core/src/test/java/org/apache/tapestry5/integration/app1/services/AppModule.java @@ -50,6 +50,7 @@ import org.apache.tapestry5.ioc.annotations.Contribute; import org.apache.tapestry5.ioc.annotations.Marker; import org.apache.tapestry5.ioc.annotations.Value; import org.apache.tapestry5.ioc.services.ServiceOverride; +import org.apache.tapestry5.services.AssetSource; import org.apache.tapestry5.services.BeanBlockContribution; import org.apache.tapestry5.services.BeanBlockSource; import org.apache.tapestry5.services.ComponentClassResolver; @@ -58,6 +59,7 @@ import org.apache.tapestry5.services.LibraryMapping; import org.apache.tapestry5.services.ResourceDigestGenerator; import org.apache.tapestry5.services.ValueEncoderFactory; import org.apache.tapestry5.services.ValueLabelProvider; +import org.apache.tapestry5.services.javascript.EsModuleConfigurationCallback; import org.apache.tapestry5.services.pageload.PageCachingReferenceTypeService; import org.apache.tapestry5.services.pageload.PagePreloader; import org.apache.tapestry5.services.pageload.PreloaderMode; @@ -476,5 +478,43 @@ public class AppModule } } + + public static final String NON_OVERRIDDEN_ES_MODULE_ID = "nonOverriden"; + + public static final String NON_OVERRIDDEN_ES_MODULE_URL = "/nonOverridenURL"; + + public static final String OVERRIDDEN_ES_MODULE_ID = "overriden"; + + public static final String OVERRIDDEN_ES_MODULE_ORIGINAL_URL = "/originalURL"; + + public static final String OVERRIDDEN_ES_MODULE_NEW_URL = "/overridenURL"; + + public static void contributeEsModuleManager( + OrderedConfiguration<EsModuleConfigurationCallback> configuration, + AssetSource assetSource) + { + final String original = "OriginalCallback"; + final String override = "OverrideCallback"; + + configuration.add(override, + o -> EsModuleConfigurationCallback.setImport(o, OVERRIDDEN_ES_MODULE_ID, OVERRIDDEN_ES_MODULE_NEW_URL), + "after:" + original); + + configuration.add(original, + o -> { + EsModuleConfigurationCallback.setImport(o, NON_OVERRIDDEN_ES_MODULE_ID, NON_OVERRIDDEN_ES_MODULE_URL); + EsModuleConfigurationCallback.setImport(o, OVERRIDDEN_ES_MODULE_ID, OVERRIDDEN_ES_MODULE_ORIGINAL_URL); + }); + + configuration.add("Outside META-INF", o -> + EsModuleConfigurationCallback.setImport(o, "outside-metainf", + assetSource.getClasspathAsset("/org/apache/tapestry5/integration/app1/es-module-outside-metainf.js").toClientURL()) + ); + + configuration.add("External URL", o -> + EsModuleConfigurationCallback.setImport(o, "external/url", "https://example.com/module.js") + ); + + } } diff --git a/tapestry-core/src/test/resources/META-INF/assets/es-modules/foo/bar.js b/tapestry-core/src/test/resources/META-INF/assets/es-modules/foo/bar.js new file mode 100644 index 000000000..14a37c87b --- /dev/null +++ b/tapestry-core/src/test/resources/META-INF/assets/es-modules/foo/bar.js @@ -0,0 +1,2 @@ +console.log("Yup, I'm an ES module!"); +document.getElementById("message").innerHTML = "ES module foo/bar imported correctly!"; \ No newline at end of file diff --git a/tapestry-core/src/test/resources/META-INF/assets/es-modules/placement/body-bottom.js b/tapestry-core/src/test/resources/META-INF/assets/es-modules/placement/body-bottom.js new file mode 100644 index 000000000..2fbf023fd --- /dev/null +++ b/tapestry-core/src/test/resources/META-INF/assets/es-modules/placement/body-bottom.js @@ -0,0 +1,2 @@ +console.log("I should go into the bottom of <body>!"); +document.getElementById("body-bottom-message").innerHTML = "ES module imported correctly (<body> bottom)!"; \ No newline at end of file diff --git a/tapestry-core/src/test/resources/META-INF/assets/es-modules/placement/body-top.js b/tapestry-core/src/test/resources/META-INF/assets/es-modules/placement/body-top.js new file mode 100644 index 000000000..4b9e268c3 --- /dev/null +++ b/tapestry-core/src/test/resources/META-INF/assets/es-modules/placement/body-top.js @@ -0,0 +1,2 @@ +console.log("I should go into the top of <body>!"); +document.getElementById("body-top-message").innerHTML = "ES module imported correctly (<body> top)!"; \ No newline at end of file diff --git a/tapestry-core/src/test/resources/META-INF/assets/es-modules/placement/head.js b/tapestry-core/src/test/resources/META-INF/assets/es-modules/placement/head.js new file mode 100644 index 000000000..ac86164ef --- /dev/null +++ b/tapestry-core/src/test/resources/META-INF/assets/es-modules/placement/head.js @@ -0,0 +1,2 @@ +console.log("I should go into <head>!"); +document.getElementById("head-message").innerHTML = "ES module imported correctly (<head>)!"; \ No newline at end of file diff --git a/tapestry-core/src/test/resources/META-INF/assets/es-modules/root-folder.js b/tapestry-core/src/test/resources/META-INF/assets/es-modules/root-folder.js new file mode 100644 index 000000000..db38f9256 --- /dev/null +++ b/tapestry-core/src/test/resources/META-INF/assets/es-modules/root-folder.js @@ -0,0 +1,2 @@ +console.log("I'm in the root folder!"); +document.getElementById("root-folder-message").innerHTML = "ES module imported correctly from the root folder!"; \ No newline at end of file diff --git a/tapestry-core/src/test/resources/META-INF/assets/es-modules/show-import-map.js b/tapestry-core/src/test/resources/META-INF/assets/es-modules/show-import-map.js new file mode 100644 index 000000000..7bfd34b48 --- /dev/null +++ b/tapestry-core/src/test/resources/META-INF/assets/es-modules/show-import-map.js @@ -0,0 +1,2 @@ +let importMap = document.querySelector("script[type=importmap]").innerHTML; +document.getElementById("import-map-listing").innerHTML = importMap; \ No newline at end of file diff --git a/tapestry-core/src/test/resources/org/apache/tapestry5/integration/app1/es-module-outside-metainf.js b/tapestry-core/src/test/resources/org/apache/tapestry5/integration/app1/es-module-outside-metainf.js new file mode 100644 index 000000000..f41c552ca --- /dev/null +++ b/tapestry-core/src/test/resources/org/apache/tapestry5/integration/app1/es-module-outside-metainf.js @@ -0,0 +1,2 @@ +console.log("ES module outside /META-INF/assets/es-modules/"); +document.getElementById("outside-metainf-message").innerHTML = "ES module correctly imported from outside /META-INF/assets/es-modules!"; \ No newline at end of file diff --git a/tapestry-core/src/test/resources/org/apache/tapestry5/integration/app1/pages/EsModuleDemo.tml b/tapestry-core/src/test/resources/org/apache/tapestry5/integration/app1/pages/EsModuleDemo.tml new file mode 100644 index 000000000..4c6c3fde5 --- /dev/null +++ b/tapestry-core/src/test/resources/org/apache/tapestry5/integration/app1/pages/EsModuleDemo.tml @@ -0,0 +1,30 @@ +<html t:type="Border" xmlns:t="http://tapestry.apache.org/schema/tapestry_5_3.xsd" xmlns:p="tapestry:parameter"> + + <h1>ES Module Import Demo</h1> + + <p> + <t:if test="overrideEsModuleImportAgain"> + <p:then> + <a t:type="EventLink" event="disableOverride" class="switch">Don't override ES module import again.</a> + </p:then> + <p:else> + <a t:type="EventLink" event="enableOverride" class="switch">Override ES module import again.</a> + </p:else> + </t:if> + </p> + + <p id="message"/> + <p id="head-message"/> + <p id="body-top-message"/> + <p id="body-bottom-message"/> + <p id="root-folder-message"/> + <p id="outside-metainf-message"/> + + <p> + Import map: + </p> + <pre id="import-map-listing"/> + + <p id="last-body-element"></p> + +</html> \ No newline at end of file