AEM Cloud Service - Combine Icons Create SVG Stream Dispatcher Invalidate on Folder Update Publish

Goal

Adobe Experience Manager 2022.11.9850.20221116T162329Z-220900

Usecase here is to combine all SVG icons used on the site into a single downloadable SVG for improving page load time. Moreover, a user can upload and publish new SVG Icons, use them on pages, so the icons are controlled by page authors and not part of a code deployment.

Thank you unknown coders for the snippets

Demo | Package Install | Github


1) User uploads/publishes/unpublishes SVG Icons to folder /content/dam/eaem-svg-stream-clear-cache


2) Request url /content/api/eaem/sprite.svg combines all SVG Icons in the folder and returns a single svg file, sample below..

view-source:https://publish-p10961-e880305.adobeaemcloud.com/content/api/eaem/sprite.svg

<svg xmlns="http://www.w3.org/2000/svg" ....
<symbol id="arrow_left" viewBox="0 0 22 22">
<path d="M11.9716157,19......"></path>
</symbol>
<symbol id="arrow_right" viewBox="0 0 22 22">
<path d="M10.0284914,....."></path>
</symbol>
...
...
</svg>


3) User selects the SVG icon in authoring page component dialog


4) In component HTL script, code renders icon on page using <use xlink:href=...> tag, for example...

<div>
...
<svg focusable="false">
<use xlink:href="#arrow_left"></use>
</svg>
</div>


5) When user uploads a new icon (or removes/unpublishes icons from folder) on demand cache INVALIDATE request sent to dispatcher clears the cached file /content/api/eaem/sprite.svg, so published icons are available in stream and rendered on page...

https://author-p10961-e880305.adobeaemcloud.com/ui#/aem/libs/granite/distribution/content/distribution-agent.html?agentName=publish


6)  Any new requests to the SVG file /content/api/eaem/sprite.svg first miss the cache (logs actionmiss), gets latest SVG stream from publish and subsequent requests are served from dispatcher (logs actionhit)


Solution

1) Create a servlet apps.eaem.assets.core.servlets.SVGExporter with path /bin/eaem/sprite for creating the SVG file adding all icons available in /content/dam/eaem-svg-stream-clear-cache 

package apps.eaem.assets.core.servlets;

import com.day.cq.dam.api.Asset;
import org.apache.commons.io.FilenameUtils;
import org.apache.commons.io.IOUtils;
import org.apache.sling.api.SlingHttpServletRequest;
import org.apache.sling.api.SlingHttpServletResponse;
import org.apache.sling.api.resource.Resource;
import org.apache.sling.api.servlets.SlingSafeMethodsServlet;
import org.osgi.service.component.annotations.Component;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.servlet.Servlet;
import javax.servlet.ServletException;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.*;
import java.util.function.Predicate;
import java.util.stream.Collectors;

import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.parser.Parser;

@Component(
name = "Experience AEM SVG Exporter",
immediate = true,
service = Servlet.class,
property = {
"sling.servlet.methods=GET",
"sling.servlet.paths=/bin/eaem/sprite"
}
)
public class SVGExporter extends SlingSafeMethodsServlet {
private static final Logger LOGGER = LoggerFactory.getLogger(SVGExporter.class);

private static final String ICONS_FOLDER = "/content/dam/eaem-svg-stream-clear-cache";
private static final String ID_ATTRIBUTE = "id";
private static final String XLINK_ATTRIBUTE = "xlink:href";

@Override
protected void doGet(SlingHttpServletRequest request, SlingHttpServletResponse response)
throws ServletException, IOException {
List<Resource> icons = new ArrayList<Resource>();

Resource iconsFolder = request.getResourceResolver().getResource(ICONS_FOLDER);

List<String> iconStrings = getAllResourceChildren(iconsFolder, r -> r.getName().endsWith(".svg"), icons)
.stream()
.map(this::getIconAsString)
.filter(Objects::nonNull)
.collect(Collectors.toList());

response.setContentType("image/svg+xml; charset=UTF-8");

PrintWriter writer = response.getWriter();
writer.write("<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\">");
iconStrings.forEach(writer::write);
writer.write("</svg>");
}

private static Collection<Resource> getAllResourceChildren(Resource resource, Predicate<Resource> predicate,
List<Resource> collection) {
Iterable<Resource> children = resource.getChildren();

for (Resource child : children) {
if(predicate.test(child)){
collection.add(child);
}else{
getAllResourceChildren(child, predicate, collection);
}
}

return collection;
}

private String getIconAsString(Resource resource) {
String result = null;
String name = FilenameUtils.getBaseName(resource.getName());
Asset asset = resource.adaptTo(Asset.class);

if (asset == null) {
return result;
}

try{
String content = IOUtils.toString(asset.getOriginal().getStream());
result = transformSvg(name, content);
} catch (IOException e) {
LOGGER.error("Error reading svg stream: {}", e);
}

return result;
}

public static String transformSvg(String id, String svgContent) {
Document doc = Jsoup.parse(svgContent, "", Parser.xmlParser());
Element svg = doc.select("svg").first();

Element symbol = doc.createElement("symbol");
symbol.attr(ID_ATTRIBUTE, id);
symbol.attr("viewBox", svg.attr("viewBox"));

Optional.ofNullable(svg.select("style").first()).ifPresent(style -> {
String cssSelector = String.format("#%s .$1{", id);
String styleContent = style.html().replaceAll("\\.(-?[_a-zA-Z]+[_a-zA-Z0-9-]*\\s*)\\{", cssSelector);
style.html(styleContent);
});

svg.children().forEach(child -> {
child.getElementsByAttribute(ID_ATTRIBUTE).forEach(element -> {
String value = String.format("%s-%s", id, element.attr(ID_ATTRIBUTE));
element.attr(ID_ATTRIBUTE, value);
});
child.getElementsByAttribute(XLINK_ATTRIBUTE).forEach(element -> {
String value = String.format("#%s-%s", id, element.attr(XLINK_ATTRIBUTE).substring(1));
element.attr(XLINK_ATTRIBUTE, value);
});

symbol.appendChild(child);
});

return symbol.outerHtml();
}
}


2) Create the content api node /content/api/eaem/sprite.svg with sling:resourceType set to above servlet path /bin/eaem/sprite

<?xml version="1.0" encoding="UTF-8"?>
<jcr:root xmlns:sling="http://sling.apache.org/jcr/sling/1.0" xmlns:jcr="http://www.jcp.org/jcr/1.0"
xmlns:nt="http://www.jcp.org/jcr/nt/1.0"
jcr:primaryType="nt:unstructured">
<eaem jcr:primaryType="nt:unstructured">
<sprite.svg
jcr:primaryType="nt:unstructured"
sling:resourceType="/bin/eaem/sprite"/>
</eaem>
</jcr:root>


3) Create a folder update event listener apps.eaem.assets.core.listeners.IconFolderUpdatedListener for clearing the svg file cache on dispatcher when new icons are added to folder /content/dam/eaem-svg-stream-clear-cache

package apps.eaem.assets.core.listeners;

import org.apache.commons.collections4.CollectionUtils;
import org.apache.sling.api.resource.LoginException;
import org.apache.sling.api.resource.ResourceResolver;
import org.apache.sling.api.resource.ResourceResolverFactory;
import org.apache.sling.discovery.DiscoveryService;

import static org.apache.sling.distribution.DistributionRequestType.DELETE;
import static org.apache.sling.distribution.event.DistributionEventProperties.DISTRIBUTION_TYPE;
import static org.osgi.service.event.EventConstants.EVENT_TOPIC;

import org.apache.sling.distribution.DistributionRequest;
import org.apache.sling.distribution.DistributionRequestType;
import org.apache.sling.distribution.Distributor;
import org.apache.sling.distribution.SimpleDistributionRequest;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
import org.osgi.service.event.Event;
import org.osgi.service.event.EventHandler;
import org.osgi.service.metatype.annotations.AttributeDefinition;
import org.osgi.service.metatype.annotations.AttributeType;
import org.osgi.service.metatype.annotations.Designate;
import org.osgi.service.metatype.annotations.ObjectClassDefinition;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.*;
import java.util.stream.Collectors;

import static org.apache.sling.distribution.DistributionRequestType.ADD;

import static org.apache.sling.distribution.event.DistributionEventTopics.AGENT_PACKAGE_DISTRIBUTED;
import static org.apache.sling.distribution.event.DistributionEventProperties.DISTRIBUTION_PATHS;

@Component(immediate = true, service = EventHandler.class, property = {
EVENT_TOPIC + "=" + AGENT_PACKAGE_DISTRIBUTED
})
@Designate(ocd=IconFolderUpdatedListener.Configuration.class)
public class IconFolderUpdatedListener implements EventHandler {
private static final Logger LOG = LoggerFactory.getLogger(IconFolderUpdatedListener.class);

private static final String ICONS_FOLDER = "/content/dam/eaem-svg-stream-clear-cache";
private static final String SPRITE_CACHE_PATH = "/content/api/eaem/sprite.svg";
private static final String PUBLISH_AGENT = "publish";
private static final String EAEM_SERVICE_USER = "eaem-service-user";

private String fastlyPurgeKey = "";

@Reference
private DiscoveryService discoveryService;

@Reference
private Distributor distributor;

@Reference
private ResourceResolverFactory resolverFactory;

@Activate
protected void activate(IconFolderUpdatedListener.Configuration configuration) {
this.fastlyPurgeKey = configuration.fastlyPurgeKey();
}

@Override
public void handleEvent(Event event) {
String distributionType = (String) event.getProperty(DISTRIBUTION_TYPE);

LOG.info("distributionType------->" + distributionType);

if (!ADD.name().equals(distributionType) && !DELETE.equals(distributionType)) {
return;
}

/*boolean isLeader = discoveryService.getTopology().getLocalInstance().isLeader();

if (!isLeader) {
return;
}*/

String[] paths = (String[]) event.getProperty(DISTRIBUTION_PATHS);

List<String> iconPaths = Arrays.stream(paths).map(this::isIconFolderPath)
.filter(Objects::nonNull)
.collect(Collectors.toList());

if(CollectionUtils.isEmpty(iconPaths)){
LOG.info("iconPaths EMPTY, not invalidating sprite cache {}" , SPRITE_CACHE_PATH);
return;
}

LOG.info("Icons folder {} assets updated/deleted/published/unpublished, removing sprite cache in dispatcher", SPRITE_CACHE_PATH);

ResourceResolver resolver = getServiceResourceResolver(resolverFactory);

DistributionRequest distributionRequest = new SimpleDistributionRequest(DistributionRequestType.INVALIDATE,
false, SPRITE_CACHE_PATH);

distributor.distribute(PUBLISH_AGENT, resolver, distributionRequest);
}

private static ResourceResolver getServiceResourceResolver(ResourceResolverFactory resourceResolverFactory) {
Map<String, Object> subServiceUser = new HashMap<>();
subServiceUser.put(ResourceResolverFactory.SUBSERVICE, EAEM_SERVICE_USER);
try {
return resourceResolverFactory.getServiceResourceResolver(subServiceUser);
} catch (LoginException ex) {
LOG.error("Could not login as SubService user {}", EAEM_SERVICE_USER, ex);
return null;
}
}

private String isIconFolderPath(String path){
if(path.startsWith(ICONS_FOLDER)){
return path;
}

return null;
}

@ObjectClassDefinition(
name = "Experience AEM - Icon Folder Updated Listener Configuration"
)
@interface Configuration {
@AttributeDefinition(
name = "Fastly Purge Key",
description = "Fastly Purge Key for dumping CDN Cache",
type = AttributeType.STRING
)
String fastlyPurgeKey() default "";
}
}


4) To test using the icons on pages, create a component /apps/eaem-svg-stream-clear-cache/components/helloworld/_cq_dialog/.content.xml

<?xml version="1.0" encoding="UTF-8"?>
<jcr:root xmlns:sling="http://sling.apache.org/jcr/sling/1.0" xmlns:cq="http://www.day.com/jcr/cq/1.0"
xmlns:jcr="http://www.jcp.org/jcr/1.0" xmlns:nt="http://www.jcp.org/jcr/nt/1.0"
jcr:primaryType="nt:unstructured"
jcr:title="Properties"
sling:resourceType="cq/gui/components/authoring/dialog">
<content
jcr:primaryType="nt:unstructured"
sling:resourceType="granite/ui/components/coral/foundation/fixedcolumns">
<items jcr:primaryType="nt:unstructured">
<column
jcr:primaryType="nt:unstructured"
sling:resourceType="granite/ui/components/coral/foundation/container">
<items jcr:primaryType="nt:unstructured">
<text
jcr:primaryType="nt:unstructured"
sling:resourceType="granite/ui/components/coral/foundation/form/pathfield"
fieldLabel="Icon Path"
rootPath="/content/dam/eaem-svg-stream-clear-cache"
name="./iconPath"/>
</items>
</column>
</items>
</content>
</jcr:root>


5) Add sling model apps.eaem.assets.core.models.HelloWorldModel reading icon path used in the component rendering..

package apps.eaem.assets.core.models;


import javax.annotation.PostConstruct;

import com.adobe.xfa.ut.StringUtils;
import org.apache.sling.api.resource.Resource;
import org.apache.sling.models.annotations.Model;
import org.apache.sling.models.annotations.injectorspecific.InjectionStrategy;
import org.apache.sling.models.annotations.injectorspecific.ValueMapValue;

@Model(adaptables = Resource.class)
public class HelloWorldModel {

@ValueMapValue(injectionStrategy=InjectionStrategy.OPTIONAL)
protected String iconPath;

private String iconName;

@PostConstruct
protected void init() {
if(!StringUtils.isEmpty(iconPath)){
iconName = iconPath.substring(iconPath.lastIndexOf("/") + 1, iconPath.lastIndexOf(".svg"));
}
}

public String getIconName() {
return iconName;
}
}


6) Add HTL script /apps/eaem-svg-stream-clear-cache/components/helloworld/helloworld.html for rendering selected SVG Icon...

<div data-sly-use.model="apps.eaem.assets.core.models.HelloWorldModel"
style="margin: 20px 0 20px 0"
data-sly-test="${!model.iconName}">
Hello, select a svg icon in dialog!
</div>

<div data-sly-use.model="apps.eaem.assets.core.models.HelloWorldModel" data-sly-test="${model.iconName}">
<div style="margin-bottom: 20px">
Hello, you've selected the icon : ${model.iconName}
</div>

<svg focusable="false">
<use xlink:href="#${model.iconName}"></use>
</svg>
</div>


7) Add a page load clientlib JS script /apps/eaem-svg-stream-clear-cache/clientlibs/clientlib-base/load-svgs.js to download the SVG sprite file and add all svg icons content in a hidden div (icons referenced using the <use xlink:href=...> tags in components)

(function(){
const SVG_URL = "/content/api/eaem/sprite.svg",
SVG_HOLDER_CLASS = "sprite-holder";

init();

function init(){
loadSVGStream();
}

function createSvgSpriteDiv(content){
let elemDiv = document.createElement('div');
elemDiv.className = SVG_HOLDER_CLASS;
elemDiv.style.display = "none";
elemDiv.innerHTML = content;

document.body.appendChild(elemDiv);
}

function loadSVGStream(){
var request = new XMLHttpRequest();

request.open("GET", SVG_URL, true);

request.onload = () => {
if (request.status != 200) {
return;
}

createSvgSpriteDiv(request.responseText);
}

request.send();
}
}())


8) To keep things simple the SVG icon stream is only cached at dispatcher and not on CDN, using the following config in dispatcher\src\conf.d\available_vhosts\eaem.vhost (Surrogate-Control headers directs Fastly CDN not to cache)

<LocationMatch "^/content">
Header add X-EAEM-TEST "eaem-disp-01-04"
Header unset Surrogate-Control
Header always set Surrogate-Control "no-store, no-cache"
</LocationMatch>


9) Create a service user eaem-service-user for listener operations in repoinit ui.config\src\main\content\jcr_root\apps\eaem-svg-stream-clear-cache\osgiconfig\config\org.apache.sling.jcr.repoinit.RepositoryInitializer-eaem.config

scripts=[
"
create service user eaem-service-user with path system/experience-aem
set ACL for eaem-service-user
allow jcr:all on /content
allow jcr:all on /var
end
"
]


10) Provide the service user mapping in ui.config\src\main\content\jcr_root\apps\eaem-svg-stream-clear-cache\osgiconfig\config\org.apache.sling.serviceusermapping.impl.ServiceUserMapperImpl.amended~e.cfg.json

{
"user.mapping": [
"eaem-svg-stream-clear-cache.core:eaem-service-user=[eaem-service-user]"
]
}


No comments:

Post a Comment