SLING-10135 Add the ability to locate non JCR based ResourceBundle resources (#5)

SLING-10135 Add the ability to locate non JCR based ResourceBundle resources
diff --git a/bnd.bnd b/bnd.bnd
index 24986a0..221feb5 100644
--- a/bnd.bnd
+++ b/bnd.bnd
@@ -4,6 +4,8 @@
 Require-Capability:\
   osgi.implementation;filter:="(&(osgi.implementation=osgi.http)(version>=1.0)(!(version>=2.0)))"
 
+Provide-Capability: osgi.extender;osgi.extender="org.apache.sling.i18n.resourcebundle.locator.registrar";version:Version="1.0"
+
 Sling-Nodetypes:\
   SLING-INF/nodetypes/jcrlanguage.cnd,\
   SLING-INF/nodetypes/message.cnd
diff --git a/pom.xml b/pom.xml
index acc467d..166f6a4 100644
--- a/pom.xml
+++ b/pom.xml
@@ -24,7 +24,7 @@
     <parent>
         <groupId>org.apache.sling</groupId>
         <artifactId>sling-bundle-parent</artifactId>
-        <version>38</version>
+        <version>40</version>
         <relativePath />
     </parent>
 
@@ -35,7 +35,9 @@
     <description>Support for creating Java I18N ResourceBundles from repository resources.</description>
 
     <properties>
+        <project.build.outputTimestamp>2020-10-15T22:24:47Z</project.build.outputTimestamp>
         <org.ops4j.pax.exam.version>4.13.3</org.ops4j.pax.exam.version>
+        <jackrabbit.version>2.14.3</jackrabbit.version>
     </properties>
 
     <scm>
@@ -48,6 +50,15 @@
     <build>
         <plugins>
             <plugin>
+                <groupId>org.apache.rat</groupId>
+                <artifactId>apache-rat-plugin</artifactId>
+                <configuration>
+                    <excludes>
+                        <exclude>**/*.json.props</exclude>
+                    </excludes>
+                </configuration>
+            </plugin>
+            <plugin>
                 <groupId>biz.aQute.bnd</groupId>
                 <artifactId>bnd-maven-plugin</artifactId>
             </plugin>
@@ -148,7 +159,7 @@
         <dependency>
             <groupId>org.apache.jackrabbit</groupId>
             <artifactId>jackrabbit-jcr-commons</artifactId>
-            <version>2.2.4</version>
+            <version>${jackrabbit.version}</version>
             <scope>provided</scope>
         </dependency>
         <dependency>
@@ -201,25 +212,42 @@
         <dependency>
             <groupId>org.mockito</groupId>
             <artifactId>mockito-core</artifactId>
-            <version>1.10.19</version>
+            <version>3.3.3</version>
             <scope>test</scope>
         </dependency>
         <dependency>
             <groupId>org.powermock</groupId>
             <artifactId>powermock-module-junit4</artifactId>
-            <version>1.6.4</version>
+            <version>2.0.9</version>
             <scope>test</scope>
         </dependency>
         <dependency>
-            <groupId>org.powermock</groupId>
-            <artifactId>powermock-api-mockito</artifactId>
-            <version>1.6.4</version>
+        <groupId>org.powermock</groupId>
+            <artifactId>powermock-api-mockito2</artifactId>
+            <version>2.0.9</version>
+            <scope>test</scope>
+        </dependency>
+        
+        <!-- workaround SLING-7159 - the jackrabbit version from o.a.sling.commons.testing is not compatible with java9+. 
+                remove this after the deprecated dependency to o.a.sling.commons.testing has been replaced -->
+        <dependency>
+            <groupId>org.apache.jackrabbit</groupId>
+            <artifactId>jackrabbit-api</artifactId>
+            <version>${jackrabbit.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <!-- workaround SLING-7159 - the jackrabbit version from o.a.sling.commons.testing is not compatible with java9+. 
+                remove this after the deprecated dependency to o.a.sling.commons.testing has been replaced -->
+        <dependency>
+            <groupId>org.apache.jackrabbit</groupId>
+            <artifactId>jackrabbit-core</artifactId>
+            <version>${jackrabbit.version}</version>
             <scope>test</scope>
         </dependency>
         <dependency>
             <groupId>org.apache.sling</groupId>
             <artifactId>org.apache.sling.commons.testing</artifactId>
-            <version>2.0.14</version>
+            <version>2.1.2</version>
             <scope>test</scope>
             <exclusions>
                 <!-- slf4j simple implementation logs INFO + higher to stdout (we don't want that behaviour) -->
@@ -227,6 +255,16 @@
                     <groupId>org.slf4j</groupId>
                     <artifactId>slf4j-simple</artifactId>
                 </exclusion>
+                <!-- workaround SLING-7159 - the jackrabbit version is not compatible with java9+ 
+                       so exclude these here and declare newer version of jackrabbit artifacts above. -->
+                <exclusion>
+                    <groupId>org.apache.jackrabbit</groupId>
+                    <artifactId>jackrabbit-api</artifactId>
+                </exclusion>
+                <exclusion>
+                    <groupId>org.apache.jackrabbit</groupId>
+                    <artifactId>jackrabbit-core</artifactId>
+                </exclusion>
             </exclusions>
         </dependency>
         <!-- using log4j under slf4j to allow fine-grained logging config (see src/test/resources/log4j.properties) -->
diff --git a/src/main/java/org/apache/sling/i18n/impl/JcrResourceBundle.java b/src/main/java/org/apache/sling/i18n/impl/JcrResourceBundle.java
index a791954..e884e31 100644
--- a/src/main/java/org/apache/sling/i18n/impl/JcrResourceBundle.java
+++ b/src/main/java/org/apache/sling/i18n/impl/JcrResourceBundle.java
@@ -21,7 +21,7 @@
 import java.io.IOException;
 import java.io.InputStream;
 import java.util.ArrayList;
-import java.util.Arrays;
+import java.util.Collection;
 import java.util.Collections;
 import java.util.Enumeration;
 import java.util.HashSet;
@@ -76,13 +76,18 @@
 
     JcrResourceBundle(final Locale locale, final String baseName,
             final ResourceResolver resourceResolver) {
+        this(locale, baseName, resourceResolver, Collections.<LocatorPaths>emptyList());
+    }
+
+    JcrResourceBundle(final Locale locale, final String baseName,
+            final ResourceResolver resourceResolver, List<LocatorPaths> locatorPaths) {
         this.locale = locale;
         this.baseName = baseName;
 
         log.info("Finding all dictionaries for '{}' (basename: {}) ...", locale, baseName == null ? "<none>" : baseName);
 
         final long start = System.currentTimeMillis();
-        final Set<String> roots = loadPotentialLanguageRoots(resourceResolver, locale, baseName);
+        final Set<String> roots = loadPotentialLanguageRoots(resourceResolver, locale, baseName, locatorPaths);
         this.resources = loadFully(resourceResolver, roots, this.languageRoots);
 
         if (log.isInfoEnabled()) {
@@ -318,37 +323,32 @@
         this.scanForSlingMessages(dictionaryResource, targetDictionary);
     }
 
-    private Set<String> loadPotentialLanguageRoots(ResourceResolver resourceResolver, Locale locale, String baseName) {
-        final String localeString = locale.toString();
-        final String localeStringLower = localeString.toLowerCase();
-        final String localeRFC4646String = toRFC4646String(locale);
-        final String localeRFC4646StringLower = localeRFC4646String.toLowerCase();
-
+    private Set<String> loadPotentialLanguageRoots(ResourceResolver resourceResolver, Locale locale, final String baseName, Collection<LocatorPaths> locatorPaths) {
         final Set<String> paths = new LinkedHashSet<>();
+
+        PotentialLanguageRootCheck check = new PotentialLanguageRootCheck(baseName, locale);
+
+        // first consider resource bundles in the JCR repository
         final Iterator<Resource> bundles = resourceResolver.findResources(QUERY_LANGUAGE_ROOTS, "xpath");
         while (bundles.hasNext()) {
             Resource bundle = bundles.next();
-            ValueMap properties = bundle.adaptTo(ValueMap.class);
-            String language = properties.get(PROP_LANGUAGE, String.class);
-            if (language != null && language.length() > 0) {
-                if (language.equals(localeString)
-                        || language.equals(localeStringLower)
-                        || language.equals(localeRFC4646String)
-                        || language.equals(localeRFC4646StringLower)) {
-                    // basename might be a multivalue (see https://issues.apache.org/jira/browse/SLING-4547)
-                    String[] baseNames = properties.get(PROP_BASENAME, new String[]{});
-                    if (baseName == null || Arrays.asList(baseNames).contains(baseName)) {
-                        paths.add(bundle.getPath());
-                    }
+            if (check.isResourceBundle(bundle)) {
+                paths.add(bundle.getPath());
+            }
+        }
+
+        if (locatorPaths != null && !locatorPaths.isEmpty()) {
+            // next traverse the ancestors of all of the locator paths
+            LocatorPathsVisitor visitor = new LocatorPathsVisitor(check, paths);
+            for (LocatorPaths locator : locatorPaths) {
+                Resource parentResource = resourceResolver.getResource(locator.getPath());
+                if (parentResource != null) {
+                    visitor.accept(parentResource, locator.getTraverseDepth());
                 }
             }
         }
-        return Collections.unmodifiableSet(paths);
-    }
 
-    // Would be nice if Locale.toString() output RFC 4646, but it doesn't
-    private static String toRFC4646String(Locale locale) {
-        return locale.toString().replace('_', '-');
+        return Collections.unmodifiableSet(paths);
     }
 
     @Override
diff --git a/src/main/java/org/apache/sling/i18n/impl/JcrResourceBundleProvider.java b/src/main/java/org/apache/sling/i18n/impl/JcrResourceBundleProvider.java
index ae9b74e..166160f 100644
--- a/src/main/java/org/apache/sling/i18n/impl/JcrResourceBundleProvider.java
+++ b/src/main/java/org/apache/sling/i18n/impl/JcrResourceBundleProvider.java
@@ -37,6 +37,7 @@
 import java.util.ResourceBundle;
 import java.util.Set;
 import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CopyOnWriteArrayList;
 import java.util.concurrent.Semaphore;
 import java.util.regex.Pattern;
 
@@ -52,6 +53,7 @@
 import org.apache.sling.commons.scheduler.Scheduler;
 import org.apache.sling.i18n.ResourceBundleProvider;
 import org.apache.sling.serviceusermapping.ServiceUserMapped;
+import org.osgi.framework.Bundle;
 import org.osgi.framework.BundleContext;
 import org.osgi.framework.Constants;
 import org.osgi.framework.ServiceRegistration;
@@ -62,6 +64,7 @@
 import org.osgi.service.metatype.annotations.AttributeDefinition;
 import org.osgi.service.metatype.annotations.Designate;
 import org.osgi.service.metatype.annotations.ObjectClassDefinition;
+import org.osgi.util.tracker.BundleTracker;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -162,6 +165,31 @@
 
     private long invalidationDelay;
 
+    private BundleTracker<Set<LocatorPaths>> locatorPathsTracker;
+    private List<LocatorPaths> locatorPaths = new CopyOnWriteArrayList<>();
+
+    /**
+     * Add a set of paths to the set that are inspected to
+     * look for resource bundle resources
+     *
+     * @param locatorPathsSet set of locator paths to check
+     */
+    public void registerLocatorPaths(Set<LocatorPaths> locatorPathsSet) {
+        this.locatorPaths.addAll(locatorPathsSet);
+        clearCache();
+    }
+
+    /**
+     * Remove a set of paths from the set that are inspected to
+     * look for resource bundle resources
+     *
+     * @param locatorPathsSet set of locator paths to no longer check
+     */
+    public void unregisterLocatorPaths(Set<LocatorPaths> locatorPathsSet) {
+        this.locatorPaths.removeAll(locatorPathsSet);
+        clearCache();
+    }
+
     private ResourceResolver createResourceResolver() throws LoginException {
         return resourceResolverFactory.getServiceResourceResolver(null);
     }
@@ -405,6 +433,11 @@
 
         this.bundleContext = context;
         this.invalidationDelay = config.invalidation_delay();
+
+        locatorPathsTracker = new BundleTracker<>(this.bundleContext,
+                Bundle.ACTIVE, new LocatorPathsTracker(this));
+        locatorPathsTracker.open();
+
         if (this.resourceResolverFactory != null) { // this is only null during test execution!
             scheduleReloadBundles(false);
         }
@@ -413,6 +446,11 @@
 
     @Deactivate
     protected void deactivate() {
+        if (locatorPathsTracker != null) {
+            locatorPathsTracker.close();
+            locatorPathsTracker = null;
+        }
+
         clearCache();
         this.bundleContext = null;
     }
@@ -527,7 +565,7 @@
      *             is not available to access the resources.
      */
     private JcrResourceBundle createResourceBundle(final ResourceResolver resolver, final String baseName, final Locale locale) {
-        final JcrResourceBundle bundle = new JcrResourceBundle(locale, baseName, resolver);
+        final JcrResourceBundle bundle = new JcrResourceBundle(locale, baseName, resolver, locatorPaths);
 
         // set parent resource bundle
         Locale parentLocale = getParentLocale(locale);
diff --git a/src/main/java/org/apache/sling/i18n/impl/LocatorPaths.java b/src/main/java/org/apache/sling/i18n/impl/LocatorPaths.java
new file mode 100644
index 0000000..34f1cfd
--- /dev/null
+++ b/src/main/java/org/apache/sling/i18n/impl/LocatorPaths.java
@@ -0,0 +1,76 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.sling.i18n.impl;
+
+/**
+ * Details about locator paths for a bundle
+ */
+class LocatorPaths {
+    private final String path;
+    private final int traverseDepth;
+    private final long forBundleId;
+
+    public LocatorPaths(String path, int traverseDepth, long forBundleId) {
+        this.path = path;
+        this.traverseDepth = traverseDepth;
+        this.forBundleId = forBundleId;
+    }
+
+    public String getPath() {
+        return path;
+    }
+
+    public int getTraverseDepth() {
+        return traverseDepth;
+    }
+
+    public long getForBundleId() {
+        return forBundleId;
+    }
+
+    @Override
+    public int hashCode() {
+        final int prime = 31;
+        int result = 1;
+        result = prime * result + (int) (forBundleId ^ (forBundleId >>> 32));
+        result = prime * result + ((path == null) ? 0 : path.hashCode());
+        result = prime * result + traverseDepth;
+        return result;
+    }
+
+    @Override
+    public boolean equals(Object obj) {
+        if (this == obj)
+            return true;
+        if (obj == null)
+            return false;
+        if (getClass() != obj.getClass())
+            return false;
+        LocatorPaths other = (LocatorPaths) obj;
+        if (forBundleId != other.forBundleId)
+            return false;
+        if (path == null) {
+            if (other.path != null)
+                return false;
+        } else if (!path.equals(other.path))
+            return false;
+        if (traverseDepth != other.traverseDepth)
+            return false;
+        return true;
+    }
+
+}
diff --git a/src/main/java/org/apache/sling/i18n/impl/LocatorPathsTracker.java b/src/main/java/org/apache/sling/i18n/impl/LocatorPathsTracker.java
new file mode 100644
index 0000000..55d31be
--- /dev/null
+++ b/src/main/java/org/apache/sling/i18n/impl/LocatorPathsTracker.java
@@ -0,0 +1,105 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.sling.i18n.impl;
+
+import java.util.HashSet;
+import java.util.Set;
+
+import org.apache.sling.commons.osgi.ManifestHeader;
+import org.apache.sling.commons.osgi.ManifestHeader.Entry;
+import org.osgi.framework.Bundle;
+import org.osgi.framework.BundleEvent;
+import org.osgi.framework.Constants;
+import org.osgi.util.tracker.BundleTrackerCustomizer;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Handles watching bundles for registration of ResourceBundle locator paths 
+ */
+class LocatorPathsTracker implements BundleTrackerCustomizer<Set<LocatorPaths>> {
+
+    private static final String CAPABILITY_I18N_RESOURCEBUNDLE_LOCATOR = "org.apache.sling.i18n.resourcebundle.locator";
+    private static final String ATTR_PATHS = "paths";
+    private static final String ATTR_DEPTH = "depth";
+
+    private Logger log = LoggerFactory.getLogger(getClass());
+
+    private JcrResourceBundleProvider rbp;
+
+    public LocatorPathsTracker(JcrResourceBundleProvider provider) {
+        this.rbp = provider;
+    }
+
+    @Override
+    public Set<LocatorPaths> addingBundle(Bundle bundle, BundleEvent event) {
+        log.debug("Considering bundle for registering resource bundle locator paths: {}",
+                bundle.getSymbolicName());
+        Set<LocatorPaths> pathsSet = null;
+
+        String provideCapability = bundle.getHeaders().get(Constants.PROVIDE_CAPABILITY);
+        if (provideCapability != null) {
+            ManifestHeader header = ManifestHeader.parse(provideCapability);
+            for (Entry entry : header.getEntries()) {
+                if (CAPABILITY_I18N_RESOURCEBUNDLE_LOCATOR.equals(entry.getValue())) {
+                    String paths = entry.getAttributeValue(ATTR_PATHS);
+                    if (paths != null) {
+                        if (pathsSet == null) {
+                            pathsSet = new HashSet<>();
+                        }
+
+                        // use optional depth value if supplied (or 1 by default)
+                        int traversalDepth = 1;
+                        String depth = entry.getAttributeValue(ATTR_DEPTH);
+                        if (depth != null && !depth.isEmpty()) {
+                            traversalDepth = Integer.parseInt(depth);
+                        }
+
+                        // treat paths value as a csv
+                        String[] items = paths.split(",");
+                        for (String path : items) {
+                            path = path.trim(); // ignore surrounding spaces
+                            if (!path.isEmpty()) {
+                                pathsSet.add(new LocatorPaths(path, traversalDepth, bundle.getBundleId()));
+                            }
+                        }
+                    }
+                }
+            }
+
+            if (pathsSet != null) {
+                log.info("Registered {} resource bundle locator paths for bundle: {}",
+                        pathsSet.size(), bundle.getSymbolicName());
+                this.rbp.registerLocatorPaths(pathsSet);
+            }
+        }
+        return pathsSet;
+    }
+
+    @Override
+    public void modifiedBundle(Bundle bundle, BundleEvent event, Set<LocatorPaths> baseNamesSet) {
+        // no-op
+    }
+
+    @Override
+    public void removedBundle(Bundle bundle, BundleEvent event, Set<LocatorPaths> baseNamesSet) {
+        log.info("Unregistered {} resource bundle locator paths for bundle: {}",
+                baseNamesSet.size(), bundle.getSymbolicName());
+        this.rbp.unregisterLocatorPaths(baseNamesSet);
+    }
+
+}
diff --git a/src/main/java/org/apache/sling/i18n/impl/LocatorPathsVisitor.java b/src/main/java/org/apache/sling/i18n/impl/LocatorPathsVisitor.java
new file mode 100644
index 0000000..b919a40
--- /dev/null
+++ b/src/main/java/org/apache/sling/i18n/impl/LocatorPathsVisitor.java
@@ -0,0 +1,78 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.sling.i18n.impl;
+
+import java.util.Iterator;
+import java.util.Set;
+
+import org.apache.sling.api.resource.AbstractResourceVisitor;
+import org.apache.sling.api.resource.Resource;
+
+/**
+ * Visitor implementation for traversing the resources under
+ * the locator path
+ */
+class LocatorPathsVisitor extends AbstractResourceVisitor {
+    private final PotentialLanguageRootCheck check;
+    private final Set<String> paths;
+    private int traverseDepth;
+
+    /**
+     * Constructor to prepare visitor
+     * 
+     * @param check the callback to check the resource for a match
+     * @param paths the language 
+     * @param traverseDepth the maximum depth to traverse the descendant
+     */
+    public LocatorPathsVisitor(PotentialLanguageRootCheck check, Set<String> paths) {
+        this.check = check;
+        this.paths = paths;
+    }
+
+    public void accept(Resource res, int traverseDepth) {
+        this.traverseDepth = traverseDepth;
+        super.accept(res);
+    }
+
+    /**
+     * Override to stop traversal after the specified depth has
+     * been reached
+     */
+    @Override
+    protected void traverseChildren(Iterator<Resource> children) {
+        if (this.traverseDepth > 0) {
+            // decrement before drilling into children
+            this.traverseDepth--;
+            super.traverseChildren(children);
+            // back to original depth
+            this.traverseDepth++;
+        }
+    }
+
+    /**
+     * Check the given resource for a match
+     */
+    @Override
+    protected void visit(Resource res) {
+        if (check.isResourceBundle(res)) {
+            paths.add(res.getPath());
+        }
+    }
+
+}
diff --git a/src/main/java/org/apache/sling/i18n/impl/PotentialLanguageRootCheck.java b/src/main/java/org/apache/sling/i18n/impl/PotentialLanguageRootCheck.java
new file mode 100644
index 0000000..3bee10f
--- /dev/null
+++ b/src/main/java/org/apache/sling/i18n/impl/PotentialLanguageRootCheck.java
@@ -0,0 +1,76 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.sling.i18n.impl;
+
+import java.util.Arrays;
+import java.util.Locale;
+
+import org.apache.sling.api.resource.Resource;
+import org.apache.sling.api.resource.ValueMap;
+
+/**
+ * Logic to check if a resource is for a resource bundle
+ */
+class PotentialLanguageRootCheck {
+    private final String baseName;
+    private final String localeString;
+    private final String localeStringLower;
+    private final String localeRFC4646String;
+    private final String localeRFC4646StringLower;
+
+    public PotentialLanguageRootCheck(String baseName, Locale locale) {
+        this.baseName = baseName;
+        this.localeString = locale.toString();
+        this.localeStringLower = localeString.toLowerCase();
+        this.localeRFC4646String = toRFC4646String(locale);
+        this.localeRFC4646StringLower = localeRFC4646String.toLowerCase();
+    }
+    
+    /**
+     * Checks if the specified resource is a match for a resource bundle resource
+     * 
+     * @param resource the resource to check
+     */
+    public boolean isResourceBundle(Resource resource) {
+        boolean match = false;
+        ValueMap properties = resource.adaptTo(ValueMap.class);
+        if (properties != null) {
+            String language = properties.get(JcrResourceBundle.PROP_LANGUAGE, String.class);
+            if (language != null && language.length() > 0) {
+                if (language.equals(localeString)
+                        || language.equals(localeStringLower)
+                        || language.equals(localeRFC4646String)
+                        || language.equals(localeRFC4646StringLower)) {
+                    // basename might be a multivalue (see https://issues.apache.org/jira/browse/SLING-4547)
+                    String[] baseNames = properties.get(JcrResourceBundle.PROP_BASENAME, new String[]{});
+                    if (baseName == null || Arrays.asList(baseNames).contains(baseName)) {
+                        match = true;
+                    }
+                }
+            }
+        }
+        return match;
+    }
+
+    // Would be nice if Locale.toString() output RFC 4646, but it doesn't
+    private static String toRFC4646String(Locale locale) {
+        return locale.toString().replace('_', '-');
+    }
+
+}
diff --git a/src/test/java/org/apache/sling/i18n/impl/ConcurrentJcrResourceBundleLoadingTest.java b/src/test/java/org/apache/sling/i18n/impl/ConcurrentJcrResourceBundleLoadingTest.java
index b99071e..8c8acb4 100644
--- a/src/test/java/org/apache/sling/i18n/impl/ConcurrentJcrResourceBundleLoadingTest.java
+++ b/src/test/java/org/apache/sling/i18n/impl/ConcurrentJcrResourceBundleLoadingTest.java
@@ -20,9 +20,10 @@
 
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertSame;
-import static org.mockito.Matchers.any;
-import static org.mockito.Matchers.anyBoolean;
-import static org.mockito.Matchers.eq;
+import static org.mockito.AdditionalMatchers.or;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.atLeast;
 import static org.mockito.Mockito.times;
 import static org.powermock.api.mockito.PowerMockito.doAnswer;
@@ -46,6 +47,7 @@
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.junit.runners.Parameterized;
+import org.mockito.ArgumentMatchers;
 import org.mockito.Mock;
 import org.mockito.Mockito;
 import org.mockito.invocation.InvocationOnMock;
@@ -56,8 +58,6 @@
 import org.powermock.core.classloader.annotations.PrepareForTest;
 import org.powermock.modules.junit4.PowerMockRunner;
 import org.powermock.modules.junit4.PowerMockRunnerDelegate;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
 
 /**
  * Test case to verify that each bundle is only loaded once, even
@@ -73,8 +73,6 @@
         return Arrays.asList(Boolean.TRUE, Boolean.FALSE);
     }
 
-    private static final Logger LOG = LoggerFactory.getLogger(ConcurrentJcrResourceBundleLoadingTest.class);
-
     @Mock JcrResourceBundle english;
     @Mock JcrResourceBundle german;
 
@@ -108,8 +106,8 @@
             }
         });
         doReturn(null).when(provider, "createResourceResolver");
-        doReturn(english).when(provider, "createResourceBundle", any(ResourceResolver.class), eq(null), eq(Locale.ENGLISH));
-        doReturn(german).when(provider, "createResourceBundle", any(ResourceResolver.class), eq(null), eq(Locale.GERMAN));
+        doReturn(english).when(provider, "createResourceBundle", or(ArgumentMatchers.isNull(), any(ResourceResolver.class)), eq(null), eq(Locale.ENGLISH));
+        doReturn(german).when(provider, "createResourceBundle", or(ArgumentMatchers.isNull(), any(ResourceResolver.class)), eq(null), eq(Locale.GERMAN));
         Mockito.when(german.getLocale()).thenReturn(Locale.GERMAN);
         Mockito.when(english.getLocale()).thenReturn(Locale.ENGLISH);
         Mockito.when(german.getParent()).thenReturn(english);
@@ -122,7 +120,7 @@
         assertEquals(german, provider.getResourceBundle(Locale.GERMAN));
         assertEquals(german, provider.getResourceBundle(Locale.GERMAN));
 
-        verifyPrivate(provider, times(2)).invoke("createResourceBundle", any(ResourceResolver.class), eq(null), any(Locale.class));
+        verifyPrivate(provider, times(2)).invoke("createResourceBundle", or(ArgumentMatchers.isNull(), any(ResourceResolver.class)), eq(null), any(Locale.class));
     }
 
     @Test
@@ -141,8 +139,8 @@
         executor.shutdown();
         executor.awaitTermination(5, TimeUnit.SECONDS);
 
-        verifyPrivate(provider, times(1)).invoke("createResourceBundle", any(ResourceResolver.class), eq(null), eq(Locale.ENGLISH));
-        verifyPrivate(provider, times(1)).invoke("createResourceBundle", any(ResourceResolver.class), eq(null), eq(Locale.GERMAN));
+        verifyPrivate(provider, times(1)).invoke("createResourceBundle", or(ArgumentMatchers.isNull(), any(ResourceResolver.class)), eq(null), eq(Locale.ENGLISH));
+        verifyPrivate(provider, times(1)).invoke("createResourceBundle", or(ArgumentMatchers.isNull(), any(ResourceResolver.class)), eq(null), eq(Locale.GERMAN));
     }
 
     @Test
@@ -159,8 +157,8 @@
         provider.getResourceBundle(Locale.ENGLISH);
         provider.getResourceBundle(Locale.GERMAN);
 
-        verifyPrivate(provider, times(1)).invoke("createResourceBundle", any(ResourceResolver.class), eq(null), eq(Locale.ENGLISH));
-        verifyPrivate(provider, times(2)).invoke("createResourceBundle", any(ResourceResolver.class), eq(null), eq(Locale.GERMAN));
+        verifyPrivate(provider, times(1)).invoke("createResourceBundle", or(ArgumentMatchers.isNull(), any(ResourceResolver.class)), eq(null), eq(Locale.ENGLISH));
+        verifyPrivate(provider, times(2)).invoke("createResourceBundle", or(ArgumentMatchers.isNull(), any(ResourceResolver.class)), eq(null), eq(Locale.GERMAN));
     }
 
     @Test
@@ -177,8 +175,8 @@
         provider.getResourceBundle(Locale.ENGLISH);
         provider.getResourceBundle(Locale.GERMAN);
 
-        verifyPrivate(provider, times(2)).invoke("createResourceBundle", any(ResourceResolver.class), eq(null), eq(Locale.ENGLISH));
-        verifyPrivate(provider, times(2)).invoke("createResourceBundle", any(ResourceResolver.class), eq(null), eq(Locale.GERMAN));
+        verifyPrivate(provider, times(2)).invoke("createResourceBundle", or(ArgumentMatchers.isNull(), any(ResourceResolver.class)), eq(null), eq(Locale.ENGLISH));
+        verifyPrivate(provider, times(2)).invoke("createResourceBundle", or(ArgumentMatchers.isNull(), any(ResourceResolver.class)), eq(null), eq(Locale.GERMAN));
     }
 
     /**
@@ -199,7 +197,7 @@
                 newBundleReady.set(true);
                 return newBundle;
             }
-        }).when(provider, "createResourceBundle", any(ResourceResolver.class), eq(null), eq(Locale.ENGLISH));
+        }).when(provider, "createResourceBundle", or(ArgumentMatchers.isNull(), any(ResourceResolver.class)), eq(null), eq(Locale.ENGLISH));
 
         Executors.newScheduledThreadPool(1).scheduleAtFixedRate(new Runnable() {
             @Override
@@ -224,6 +222,6 @@
         }
 
         verifyPrivate(provider, verificationMode)
-                .invoke("getResourceBundleInternal", any(ResourceResolver.class), eq(null), eq(Locale.ENGLISH), anyBoolean());
+                .invoke("getResourceBundleInternal", or(ArgumentMatchers.isNull(), any(ResourceResolver.class)), eq(null), eq(Locale.ENGLISH), anyBoolean());
     }
 }
\ No newline at end of file
diff --git a/src/test/java/org/apache/sling/i18n/impl/LocatorPathsTest.java b/src/test/java/org/apache/sling/i18n/impl/LocatorPathsTest.java
new file mode 100644
index 0000000..01d4bbc
--- /dev/null
+++ b/src/test/java/org/apache/sling/i18n/impl/LocatorPathsTest.java
@@ -0,0 +1,143 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.sling.i18n.impl;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotEquals;
+
+import org.junit.Test;
+
+/**
+ * SLING-10135 Test LocatorPaths methods
+ */
+public class LocatorPathsTest {
+
+    @Test
+    public void testGetPath() {
+        LocatorPaths paths1 = new LocatorPaths("/lib/path", 1, 1);
+
+        assertEquals("/lib/path", paths1.getPath());
+    }
+
+    @Test
+    public void testGetTraverseDepth() {
+        LocatorPaths paths1 = new LocatorPaths("/lib/path", 1, 2);
+
+        assertEquals(1, paths1.getTraverseDepth());
+    }
+
+    @Test
+    public void testGetForBundleId() {
+        LocatorPaths paths1 = new LocatorPaths("/lib/path", 1, 2);
+
+        assertEquals(2, paths1.getForBundleId());
+    }
+
+    @Test
+    public void testEquals() {
+        LocatorPaths paths1 = new LocatorPaths("/lib/path", 1, 1);
+        LocatorPaths paths2 = new LocatorPaths("/lib/path", 1, 1);
+
+        assertEquals(paths1, paths2);
+    }
+
+    @Test
+    public void testEqualsNullPaths() {
+        LocatorPaths paths1 = new LocatorPaths(null, 1, 1);
+        LocatorPaths paths2 = new LocatorPaths(null, 1, 1);
+
+        assertEquals(paths1, paths2);
+    }
+
+    @Test
+    public void testSelfEquals() {
+        LocatorPaths paths1 = new LocatorPaths("/lib/path", 1, 1);
+
+        assertEquals(paths1, paths1);
+    }
+
+    @Test
+    public void testNullNotEquals() {
+        LocatorPaths paths1 = new LocatorPaths("/lib/path", 1, 1);
+
+        assertEquals(false, paths1.equals(null));
+    }
+
+
+    @Test
+    public void testHashCode() {
+        LocatorPaths paths1 = new LocatorPaths("/lib/path", 1, 1);
+        LocatorPaths paths2 = new LocatorPaths("/lib/path", 1, 1);
+
+        assertEquals(paths1.hashCode(), paths2.hashCode());
+    }
+
+    @Test
+    public void testNotEquals() {
+        LocatorPaths paths1 = new LocatorPaths("/lib/path", 1, 1);
+
+        assertNotEquals(paths1, new Object());
+    }
+
+    @Test
+    public void testNotEqualsPath() {
+        LocatorPaths paths1 = new LocatorPaths("/lib/path", 1, 1);
+        LocatorPaths paths2 = new LocatorPaths("/lib/path2", 1, 1);
+
+        assertNotEquals(paths1, paths2);
+        assertNotEquals(paths1.hashCode(), paths2.hashCode());
+    }
+
+    @Test
+    public void testNotEqualsNullPath1() {
+        LocatorPaths paths1 = new LocatorPaths(null, 1, 1);
+        LocatorPaths paths2 = new LocatorPaths("/lib/path2", 1, 1);
+
+        assertNotEquals(paths1, paths2);
+        assertNotEquals(paths1.hashCode(), paths2.hashCode());
+    }
+
+    @Test
+    public void testNotEqualsNullPath2() {
+        LocatorPaths paths1 = new LocatorPaths("/lib/path", 1, 1);
+        LocatorPaths paths2 = new LocatorPaths(null, 1, 1);
+
+        assertNotEquals(paths1, paths2);
+        assertNotEquals(paths1.hashCode(), paths2.hashCode());
+    }
+
+    @Test
+    public void testNotEqualsDepth() {
+        LocatorPaths paths1 = new LocatorPaths("/lib/path", 1, 1);
+        LocatorPaths paths2 = new LocatorPaths("/lib/path", 2, 1);
+
+        assertNotEquals(paths1, paths2);
+        assertNotEquals(paths1.hashCode(), paths2.hashCode());
+    }
+
+    @Test
+    public void testNotEqualsBundleId() {
+        LocatorPaths paths1 = new LocatorPaths("/lib/path", 1, 1);
+        LocatorPaths paths2 = new LocatorPaths("/lib/path", 1, 2);
+
+        assertNotEquals(paths1, paths2);
+        assertNotEquals(paths1.hashCode(), paths2.hashCode());
+    }
+
+}
diff --git a/src/test/java/org/apache/sling/i18n/it/I18nTestSupport.java b/src/test/java/org/apache/sling/i18n/it/I18nTestSupport.java
index d6feee1..98c67ad 100644
--- a/src/test/java/org/apache/sling/i18n/it/I18nTestSupport.java
+++ b/src/test/java/org/apache/sling/i18n/it/I18nTestSupport.java
@@ -21,11 +21,14 @@
 import org.apache.sling.testing.paxexam.TestSupport;
 import org.ops4j.pax.exam.Configuration;
 import org.ops4j.pax.exam.Option;
+import org.ops4j.pax.exam.options.ModifiableCompositeOption;
+import org.ops4j.pax.exam.options.extra.VMOption;
 
 import static org.apache.sling.testing.paxexam.SlingOptions.slingQuickstartOakTar;
 import static org.ops4j.pax.exam.CoreOptions.composite;
 import static org.ops4j.pax.exam.CoreOptions.junitBundles;
 import static org.ops4j.pax.exam.CoreOptions.options;
+import static org.ops4j.pax.exam.CoreOptions.vmOption;
 import static org.ops4j.pax.exam.cm.ConfigurationAdminOptions.factoryConfiguration;
 import static org.ops4j.pax.exam.cm.ConfigurationAdminOptions.newConfiguration;
 
@@ -48,7 +51,9 @@
             newConfiguration("org.apache.sling.jcr.base.internal.LoginAdminWhitelist")
                 .put("whitelist.bundles.regexp", "PAXEXAM-PROBE-.*")
                 .asOption(),
-            junitBundles()
+            junitBundles(),
+            optionalRemoteDebug(),
+            optionalJacocoCommand()            
         );
     }
 
@@ -60,4 +65,30 @@
         );
     }
 
+    /**
+     * Optionally configure jacoco vmOption supplied by the "jacoco.command"
+     * system property.
+     */
+    protected ModifiableCompositeOption optionalJacocoCommand() {
+        VMOption option = null;
+        String property = System.getProperty("jacoco.command");
+        if (property != null) {
+            option = vmOption(property);
+        }
+        return composite(option);
+    }
+
+    /**
+     * Optionally configure remote debugging on the port supplied by the "debugPort"
+     * system property.
+     */
+    protected ModifiableCompositeOption optionalRemoteDebug() {
+        VMOption option = null;
+        String property = System.getProperty("debugPort");
+        if (property != null) {
+            option = vmOption(String.format("-Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=%s", property));
+        }
+        return composite(option);
+    }
+    
 }
diff --git a/src/test/java/org/apache/sling/i18n/it/ResourceBundleLocatorIT.java b/src/test/java/org/apache/sling/i18n/it/ResourceBundleLocatorIT.java
new file mode 100644
index 0000000..e42f532
--- /dev/null
+++ b/src/test/java/org/apache/sling/i18n/it/ResourceBundleLocatorIT.java
@@ -0,0 +1,258 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.sling.i18n.it;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.ops4j.pax.exam.CoreOptions.composite;
+import static org.ops4j.pax.exam.CoreOptions.streamBundle;
+import static org.ops4j.pax.tinybundles.core.TinyBundles.withBnd;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Locale;
+import java.util.Map;
+import java.util.ResourceBundle;
+
+import javax.inject.Inject;
+import javax.jcr.RepositoryException;
+import javax.jcr.Session;
+
+import org.apache.commons.io.IOUtils;
+import org.apache.sling.i18n.ResourceBundleProvider;
+import org.apache.sling.jcr.api.SlingRepository;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.ops4j.pax.exam.Configuration;
+import org.ops4j.pax.exam.Option;
+import org.ops4j.pax.exam.junit.PaxExam;
+import org.ops4j.pax.exam.spi.reactors.ExamReactorStrategy;
+import org.ops4j.pax.exam.spi.reactors.PerClass;
+import org.ops4j.pax.tinybundles.core.TinyBundle;
+import org.ops4j.pax.tinybundles.core.TinyBundles;
+import org.osgi.framework.Constants;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.Multimap;
+
+/**
+ * Tests for SLING-10135 for locating resource bundle resources
+ * that are not stored in the JCR repository
+ */
+@RunWith(PaxExam.class)
+@ExamReactorStrategy(PerClass.class)
+public class ResourceBundleLocatorIT extends I18nTestSupport {
+    private final Logger logger = LoggerFactory.getLogger(ResourceBundleLocatorIT.class);
+    
+    public static final String MSG_KEY1 = "hello";
+    public static final String MSG_KEY2 = "test1";
+
+    public static final String BASENAME0 = "org.apache.sling.i18n.testing0.Resources";
+    public static final String BASENAME1 = "org.apache.sling.i18n.testing1.Resources";
+    public static final String BASENAME2 = "org.apache.sling.i18n.testing2.Resources";
+    public static final String BASENAME3 = "org.apache.sling.i18n.testing3.Resources";
+    public static final String BASENAME4 = "org.apache.sling.i18n.testing4.Resources";
+    public static final String BASENAME5 = "org.apache.sling.i18n.testing5.Resources";
+    public static final String BASENAME6 = "org.apache.sling.i18n.testing6.Resources";
+
+    @Inject
+    private SlingRepository repository;
+
+    @Inject
+    private ResourceBundleProvider resourceBundleProvider;
+
+    private Session session;
+
+    @Override
+    @Configuration
+    public Option[] configuration() {
+        // create 4 tiny bundles with different configurations for testing
+        Option[] bundle = new Option[7];
+        for (int i=0; i <=6; i++) {
+            String bundleSymbolicName = String.format("TEST-I18N-BUNDLE-%d", i);
+
+            String baseName = String.format("org.apache.sling.i18n.testing%d.Resources", i);
+
+            String traversePath;
+            if (i == 5) {
+            	traversePath = "";
+            } else if (i == 6) {
+            	traversePath = null;
+            } else {
+            	traversePath = String.format("/libs/i18n/testing%d", i); //NOSONAR
+            }
+            String resourcePath;
+            if (i <= 1) {
+                resourcePath = String.format("/libs/i18n/testing%d", i); //NOSONAR
+            } else if (i == 2) {
+                resourcePath = String.format("/libs/i18n/testing%d/folder1", i); //NOSONAR
+            } else {
+                resourcePath = String.format("/libs/i18n/testing%d/folder1/folder2", i); //NOSONAR
+            }
+            String pathInBundle = String.format("SLING-INF%s", resourcePath);
+            final Multimap<String, String> content = ImmutableListMultimap.of(
+                    pathInBundle, "Resources.json",
+                    pathInBundle, "Resources.json.props",
+                    pathInBundle, "Resources_en_CA.json",
+                    pathInBundle, "Resources_en_CA.json.props"
+            );
+            int traverseDepth;
+            if (i < 4) {
+                traverseDepth = i;
+            } else {
+                traverseDepth = 1;
+            }
+            try {
+                bundle[i] = buildContentBundle(bundleSymbolicName, 
+                                                    pathInBundle, resourcePath, 
+                                                    traversePath, traverseDepth, 
+                                                    content, baseName);
+            } catch (IOException e) {
+                throw new RuntimeException("Failed to build the content bundle", e);
+            }
+        }
+        
+        return composite(composite(super.configuration()), 
+                         composite(bundle))
+                .getOptions();
+    }
+
+    /**
+     * Add content to our test bundle
+     */
+    protected void addContent(final TinyBundle bundle, String pathInBundle, String resourcePath, Object ... args) throws IOException {
+        pathInBundle += "/" + resourcePath;
+        resourcePath = "/test-content/" + resourcePath;
+        try (final InputStream is = getClass().getResourceAsStream(resourcePath)) {
+            assertNotNull("Expecting resource to be found:" + resourcePath, is);
+            logger.info("Adding resource to bundle, path={}, resource={}", pathInBundle, resourcePath);
+            if (args != null) {
+                String value = IOUtils.toString(is, StandardCharsets.UTF_8);
+                value = String.format(value, args);
+                try (final InputStream valueStream = new ByteArrayInputStream(value.getBytes())) {
+                    bundle.add(pathInBundle, valueStream);
+                }
+            } else {
+                bundle.add(pathInBundle, is);
+            }
+        }
+    }
+
+    protected Option buildContentBundle(String bundleSymbolicName, 
+            String pathInBundle, String resourcePath, 
+            String traversePath, int traverseDepth, 
+            final Multimap<String, String> content, String basename) throws IOException {
+        final TinyBundle bundle = TinyBundles.bundle();
+        bundle.set(Constants.BUNDLE_SYMBOLICNAME, bundleSymbolicName);
+        bundle.set(Constants.REQUIRE_CAPABILITY, "osgi.extender;filter:=\"(&(osgi.extender=org.apache.sling.i18n.resourcebundle.locator.registrar)(version<=1.0.0)(!(version>=2.0.0)))\"");
+        if (traverseDepth <= 0) {
+        	if (traversePath == null) {
+                bundle.set(Constants.PROVIDE_CAPABILITY, "org.apache.sling.i18n.resourcebundle.locator");
+        	} else {
+                bundle.set(Constants.PROVIDE_CAPABILITY, String.format("org.apache.sling.i18n.resourcebundle.locator;paths=\"%s\"", traversePath));
+        	}
+        } else {
+        	if (traversePath == null) {
+                bundle.set(Constants.PROVIDE_CAPABILITY, String.format("org.apache.sling.i18n.resourcebundle.locator;depth=%d", traverseDepth));
+        	} else {
+                bundle.set(Constants.PROVIDE_CAPABILITY, String.format("org.apache.sling.i18n.resourcebundle.locator;paths=\"%s\";depth=%d", traversePath, traverseDepth));
+        	}
+        }
+        bundle.set("Sling-Bundle-Resources", String.format("%s;path:=%s;propsJSON:=props", resourcePath, pathInBundle));
+
+        for (final Map.Entry<String, String> entry : content.entries()) {
+            String entryPathInBundle = entry.getKey();
+            String entryResourcePath = entry.getValue();
+            if (entryResourcePath.endsWith(".props")) {
+                // content is a template so we need to pass the args to replace the placeholder tokens
+                addContent(bundle, entryPathInBundle, entryResourcePath, basename);
+            } else {
+                // content is not a template, so no need to pass any args
+                addContent(bundle, entryPathInBundle, entryResourcePath);
+            }
+        }
+        return streamBundle(
+            bundle.build(withBnd())
+        ).start();
+    }
+
+    @Before
+    public void setup() throws RepositoryException {
+        session = repository.loginAdministrative(null);
+    }
+
+    @After
+    public void cleanup() throws RepositoryException {
+        session.logout();
+    }
+
+    private void assertMessage(final String key, final Locale locale, final String basename, final String value) {
+        final ResourceBundle resourceBundle = resourceBundleProvider.getResourceBundle(basename, locale);
+        assertNotNull(resourceBundle);
+        assertEquals(value, resourceBundle.getString(key));
+    }
+
+    @Test
+    public void testLocatedResourceBundleDepthNotSpecified() throws RepositoryException {
+        assertMessage(MSG_KEY1, Locale.ENGLISH, BASENAME0, "World");
+        assertMessage(MSG_KEY1, Locale.CANADA, BASENAME0, "Canada");
+    }
+
+    @Test
+    public void testLocatedResourceBundleDepth1() throws RepositoryException {
+        assertMessage(MSG_KEY1, Locale.ENGLISH, BASENAME1, "World");
+        assertMessage(MSG_KEY1, Locale.CANADA, BASENAME1, "Canada");
+    }
+
+    @Test
+    public void testLocatedResourceBundleDepth2() throws RepositoryException {
+        assertMessage(MSG_KEY1, Locale.ENGLISH, BASENAME2, "World");
+        assertMessage(MSG_KEY1, Locale.CANADA, BASENAME2, "Canada");
+    }
+
+    @Test
+    public void testLocatedResourceBundleDepth3() throws RepositoryException {
+        assertMessage(MSG_KEY1, Locale.ENGLISH, BASENAME3, "World");
+        assertMessage(MSG_KEY1, Locale.CANADA, BASENAME3, "Canada");
+    }
+
+    @Test
+    public void testNotLocatedResourceBundle() throws RepositoryException {
+        assertMessage(MSG_KEY1, Locale.ENGLISH, BASENAME4, MSG_KEY1);
+        assertMessage(MSG_KEY1, Locale.CANADA, BASENAME4, MSG_KEY1);
+    }
+
+    @Test
+    public void testNotLocatedResourceBundleEmptyLocatorPath() throws RepositoryException {
+        assertMessage(MSG_KEY1, Locale.ENGLISH, BASENAME5, MSG_KEY1);
+        assertMessage(MSG_KEY1, Locale.CANADA, BASENAME5, MSG_KEY1);
+    }
+
+    @Test
+    public void testNotLocatedResourceBundleNotSpecifiedLocatorPath() throws RepositoryException {
+        assertMessage(MSG_KEY1, Locale.ENGLISH, BASENAME6, MSG_KEY1);
+        assertMessage(MSG_KEY1, Locale.CANADA, BASENAME6, MSG_KEY1);
+    }
+}
diff --git a/src/test/resources/test-content/Resources.json b/src/test/resources/test-content/Resources.json
new file mode 100644
index 0000000..c6d82d7
--- /dev/null
+++ b/src/test/resources/test-content/Resources.json
@@ -0,0 +1,4 @@
+{
+  "hello":"World",
+  "test1":"value1",
+}
diff --git a/src/test/resources/test-content/Resources.json.props b/src/test/resources/test-content/Resources.json.props
new file mode 100644
index 0000000..f7e5140
--- /dev/null
+++ b/src/test/resources/test-content/Resources.json.props
@@ -0,0 +1,4 @@
+{
+  "jcr:language":"en",
+  "sling:basename":"%s"
+}
diff --git a/src/test/resources/test-content/Resources_en_CA.json b/src/test/resources/test-content/Resources_en_CA.json
new file mode 100644
index 0000000..9fc837e
--- /dev/null
+++ b/src/test/resources/test-content/Resources_en_CA.json
@@ -0,0 +1,4 @@
+{
+  "hello":"Canada",
+  "test1":"value1ca",
+}
diff --git a/src/test/resources/test-content/Resources_en_CA.json.props b/src/test/resources/test-content/Resources_en_CA.json.props
new file mode 100644
index 0000000..97c8711
--- /dev/null
+++ b/src/test/resources/test-content/Resources_en_CA.json.props
@@ -0,0 +1,4 @@
+{
+  "jcr:language":"en_CA",
+  "sling:basename":"%s"
+}