N
Naveenr.dev
Chapter 105
11 min read2026-08-28

Sitemap Generation Edge Cases: Multi-Value Properties, Vanity URLs, and Template-Aware Alternates

Three real edge cases that break a custom Sling sitemap generator in ways code review rarely catches — an untyped multi-value property read that silently defeats a noindex check, alternate-language links that point at vanity paths instead of real content paths, and hreflang links added between pages that don't actually share a template family.

Content Objective

In this chapter, you'll understand:

  • Why reading a multi-value JCR property through an untyped ValueMap.get(name) call can silently break a string-equality check
  • How a custom ResourceTreeSitemapGenerator resolves alternate-language links that turn out to be vanity paths rather than real content paths
  • Why attaching hreflang alternates between pages needs a template compatibility check, not just a locale-sibling relationship
  • The general lesson: extending a framework class correctly at the API level doesn't guarantee the data flowing through it is shaped the way the code assumes

The Problem: A noindex Check That Never Fired

A site's SEO team had configured a subset of pages with AEM's built-in Robots Tags field — a multi-select checkbox group letting an author combine noindex, nofollow, and other directives on a single page. The custom sitemap generator was supposed to skip any page carrying noindex. It compiled fine, its own unit tests passed, and it had been running in production for months.

Then a routine SEO audit flagged that every single page marked noindex in the CMS was still showing up in the live sitemap.xml, with no exceptions. Not "some slipped through" — literally all of them, regardless of which combination of robots directives was set.

The bug was two lines of code, and it had nothing to do with sitemap logic at all — it was a Java array's default toString() implementation, applied somewhere nobody expected to encounter an array.

Architecture

Sling's sitemap module gives you ResourceTreeSitemapGenerator as an extension point: it handles walking a resource tree and building sitemap XML, and your subclass overrides addResource(name, sitemap, resource) to decide, per resource, whether it belongs in the sitemap and what metadata (last-modified, alternate-language links, custom extensions) to attach to its URL entry. This is the same extension model used in the multi-language sitemap work covered in the Multi-Language & Global Sites chapter — the generator doesn't reimplement tree-walking, it adds page-specific decisions on top of it.

The three issues in this chapter all live inside that one addResource override, at three different points:

  1. Reading the page's robots-tags property to decide whether to skip it.
  2. Resolving an alternate-language link's target path before externalizing it.
  3. Deciding whether an alternate-language link should be attached at all.

How It Works

1. The multi-value property read. AEM's Robots Tags field stores its value as a multi-value JCR property — a checkbox group where an author can select any combination of noindex, nofollow, noarchive, and so on. When code reads that property through Sling's ValueMap using the single-argument, untyped get(String name) method, a multi-value property comes back as an array type (typically String[]), not a single string. Calling .toString() directly on that array does not produce a human-readable, comma-joined value — it produces Java's default Object.toString() output for an array, something like [Ljava.lang.String;@6bc7c054. A check like "noindex".equalsIgnoreCase(thatValue) will never be true against that string, no matter what the author actually selected.

The fix is to read the property with its correct multi-value type — String[].class via the typed ValueMap.get(name, String[].class) overload, or List<String> — and then check membership in that collection, not string equality against a .toString() of the raw untyped read.

2. Alternate links that are vanity paths, not content paths. A page's alternate-language links (its locale siblings) sometimes come back as short, human-friendly vanity paths (redirect-mapped URLs) rather than real /content/... resource paths — depending on how the alternate-language API in use resolves them. Externalizing a vanity path directly can produce a URL that either 404s or double-redirects. The generator needs to resolve a non-/content alternate path back to a real resource, scoped to the correct site root, before externalizing it — and needs a documented fallback (log and skip that one alternate) when resolution fails, rather than emitting a broken hreflang link.

3. Template-mismatched alternates. Not every page that shares a locale-sibling relationship with the current page is actually the same page in a different language. A promotional microsite built on a different template family, for instance, might sit at a URL that superficially looks like a locale sibling but isn't part of the standard page template group the multi-language structure assumes. Attaching an hreflang link between two pages that aren't really translations of each other is worse than attaching none — it tells search engines two unrelated pages are equivalent. The generator needs a template-compatibility check before attaching an alternate link, not just a "did the API return something" check.

Real Project Example

A production RegionalSitemapGenerator extended ResourceTreeSitemapGenerator and read the robots-tags property like this:

java
String robotsTags = page.getProperties().containsKey(SeoProperties.PN_ROBOTS_TAGS)
        ? page.getProperties().get(SeoProperties.PN_ROBOTS_TAGS).toString()
        : StringUtils.EMPTY;
if (!StringUtils.equalsIgnoreCase("noindex", robotsTags)) {
    // ... add the page's URL to the sitemap
}

page.getProperties() returns a ValueMap. .get(name) — the single-argument, untyped overload — hands back whatever the underlying JCR property type converts to, and for a multi-value property that's an array. .toString() on that array is where the check quietly stopped working: robotsTags was never actually "noindex", it was the array's identity string, for every single page that had the Robots Tags field set through the standard multi-select UI. The equalsIgnoreCase check always evaluated to false, meaning "not noindex," so nothing was ever excluded.

Alongside that, the same generator resolved alternate-language links directly through the externalizer without checking whether the alternate's path pointed at real content:

java
for (Map.Entry<Locale, String> alt : alternates.entrySet()) {
    String externalAlt = externalizer.externalize(resource.getResourceResolver().getResource(alt.getValue()));
    url.addExtension(AlternateLanguageExtension.class).setHref(externalAlt).setLocale(alt.getKey());
}

If alt.getValue() was a vanity path rather than a /content/... path, getResource() on it either resolved to the vanity-redirect resource itself (producing an hreflang link that pointed at a redirect, not the destination page) or returned null, producing a malformed link with no destination at all.

Production Troubleshooting

Fixed property read, using the typed multi-value overload instead of untyped .toString():

java
private boolean isNoIndex(Page page) {
    String[] robotsTags = page.getProperties().get(SeoProperties.PN_ROBOTS_TAGS, new String[0]);
    return Arrays.stream(robotsTags).anyMatch(tag -> "noindex".equalsIgnoreCase(tag));
}

Vanity-path resolution, scoped to the site root before externalizing, with an explicit skip-and-log fallback:

java
private Optional<String> resolveAlternateContentPath(String candidatePath, Resource siteRoot, ResourceResolver resolver) {
    if (candidatePath.startsWith(CONTENT_ROOT_PREFIX)) {
        return Optional.of(candidatePath);
    }
    Resource resolved = resolver.resolve(siteRoot.getPath() + candidatePath);
    if (ResourceUtil.isNonExistingResource(resolved)) {
        LOG.warn("Could not resolve alternate-language vanity path '{}' under site root '{}' — skipping this alternate", candidatePath, siteRoot.getPath());
        return Optional.empty();
    }
    return Optional.of(resolved.getPath());
}

Template-aware filtering, added as an explicit check before an alternate is attached:

java
private boolean isTemplateCompatible(Resource alternateResource) {
    Resource content = alternateResource.getChild(JcrConstants.JCR_CONTENT);
    if (content == null) {
        return false;
    }
    String template = content.getValueMap().get(NameConstants.NN_TEMPLATE, String.class);
    return StringUtils.isNotBlank(template) && template.startsWith(STANDARD_PAGE_TEMPLATE_ROOT);
}

Each of these three checks was independently unit-testable and independently the actual root cause of a distinct real symptom — the fix for one didn't require touching the other two.

Why Architects Care

An untyped ValueMap.get(name) call compiles and passes a naive unit test that mocks the return value as a plain string — the failure only shows up against a real multi-value JCR property, which is exactly the kind of gap between "compiles and passes tests" and "correct against real data" that a code review focused on logic (not on the actual runtime type flowing through a generic API) will miss every time. The lesson generalizes past sitemaps: any time code reads a property through an untyped, "just give me an Object" API and immediately calls .toString() on it, that's worth a second look for whether the underlying property is ever multi-valued.

Summary

A custom sitemap generator's noindex exclusion silently never worked because reading a multi-value Robots Tags property through an untyped ValueMap.get() call and calling .toString() on the result produced Java's default array-identity string, not a comparable value — fixed by reading the property with its correct String[] type. Two related but separate issues in the same generator — alternate-language links pointing at unresolved vanity paths, and hreflang links attached between template-incompatible pages — were fixed with an explicit path-resolution fallback and a template-compatibility check respectively.

What's Next

The next post in this series works through this exact incident as a full real-world scenario — the audit that caught it, the fix, and the regression tests that pin all three behaviors down.

Enjoyed this chapter?

Get an email when I publish the next chapter. No spam — just new technical deep-dives.

Comments

Share feedback or questions about this blog post.

No comments yet. Be the first to share your thoughts.