For AI agents: the complete documentation index is available at https://nhanbe.id.vn/llms.txt, the full documentation bundle is available at https://nhanbe.id.vn/llms-full.txt, and this page is available as Markdown at https://nhanbe.id.vn/aem/advanced/htl-slightly.md.

HTL (Slightly)

08 – HTL (Sightly)

Target: 3–4 years experienced AEM Developer Syllabus points covered (17, 18): Point 17 — "How will a model be called in HTL?" Point 18 — "Sightly tags and their uses." Project domain: a global energy technology company's marketing site.


Before we start — a note on the name

Your syllabus says "sightly tags." That is worth a moment, because it tells you something useful.

Sightly was the original name. Adobe introduced it in AEM 6.0 as the replacement for JSP. In AEM 6.3 it was renamed to HTL — HTML Template Language — when Adobe donated it to Apache Sling as an open specification.

They are the same thing. But a lot of people in the industry still say Sightly, and interviewers who learned AEM before 6.3 often do.

How to handle it if an interviewer says "Sightly": just answer normally and use "HTL" naturally in your response. Something like "Sightly — or HTL as it's called since 6.3 — is..." That signals you know both names and roughly when the change happened, without correcting them. Correcting an interviewer on terminology is never worth the point.


The one idea that makes this whole topic click

Most people describe HTL as a limited templating language, as though the limits are an unfortunate compromise.

That is backwards. The restrictions are the entire design.

HTL deliberately cannot express arbitrary logic. You cannot write a loop with a counter, call a static method, or perform a calculation. And that is not because Adobe ran out of time — it is the point.

Here is what JSP allowed:

<%
    // This is real, valid JSP. People wrote this.
    String title = currentNode.getProperty("jcr:title").getString();
    if (title.length() > 50) {
        title = title.substring(0, 50) + "...";
    }
    ResourceResolver resolver = resource.getResourceResolver();
    // ... twenty more lines of business logic ...
%>
<h2><%= title %></h2>

Business logic in the view. Untestable, unreviewable, and impossible for a front-end developer to touch safely. And note there is no escaping on that output — a classic XSS hole.

HTL makes that impossible. There is no syntax for it. So the logic has to move into a Sling Model, where it can be unit tested — which is exactly what file 05 was about.

Say this in an interview and it lands:

"The thing I'd emphasise about HTL is that its restrictions are the feature, not a limitation. It deliberately can't express arbitrary logic, which forces business logic out of the template and into a Sling Model where it's testable and reviewable. JSP allowed scriptlets, and that's how you end up with business rules hidden in a view nobody can test. HTL also escapes output automatically based on context, so XSS becomes something you have to actively opt out of rather than something you have to remember to prevent."

That answer covers security, testability and maintainability in three sentences, and it frames you as someone who understands why rather than what.


1. Introduction

1.1 What is HTL?

HTL is AEM's server-side templating language. An HTL file is a valid HTML file with special data-sly-* attributes that AEM processes when rendering.

Three properties that follow from that:

It is valid HTML. You can open an HTL file in a browser and it renders — not with real data, but the structure is visible. data-sly-* are just data attributes, which browsers ignore. That means a front-end developer can work with the file directly.

It is compiled. AEM compiles HTL into a Java servlet at runtime, then caches it. So HTL is not interpreted per request — the performance is closer to Java than to a scripting language.

It is server-side only. Everything happens on AEM before the HTML reaches the browser.

1.2 Why HTL replaced JSP

Four reasons, and giving several is what makes a good answer:

Security. HTL escapes output automatically, and — crucially — it picks the escaping based on where the value appears. A value in element content is HTML-escaped; the same value in an href is URI-escaped. In JSP you had to call the XSS API manually every single time, and one forgotten call was a vulnerability.

Separation of concerns. HTL cannot express business logic, so logic has to live in a Sling Model.

Front-end friendliness. A valid HTML file that opens in a browser. A front-end developer can work with it without understanding Java.

Fewer ways to be wrong. Less expressive means fewer mistakes possible.

1.3 A real project example to adapt

"All our components use HTL — we have no JSP left. The pattern is consistent: data-sly-use binds a Sling Model, the HTL guards its outer element with data-sly-test so an unconfigured component renders nothing rather than an empty div, and all the logic lives in the model. We use <sly> wherever we need an HTL construct without an element in the output, so the markup stays clean for the front-end team. The only place we use context='unsafe' is emitting JSON-LD structured data, and that's justified because the JSON is produced by a serialiser rather than concatenated by hand."

That covers the pattern, the empty-state discipline, markup cleanliness, and the one security exception with its justification.


2. Core Concepts

2.1 How you call a Sling Model from HTL (syllabus point 17)

Your syllabus asks this directly. The answer is data-sly-use.

<div data-sly-use.cta="com.energy.core.models.CtaModel">
    ${cta.linkText}
</div>

Breaking it down piece by piece:

data-sly-use is the block element that instantiates something.

.cta is the variable name you choose. It could be .model, .banner, anything. This trips people up — the part after the dot is not fixed, it is your identifier for the rest of the template.

The value is the fully qualified class name.

${cta.linkText} then calls the getter. And this follows the JavaBeans convention, which is the detail worth stating:

In HTLCalls in Java
${cta.linkText}getLinkText()
${cta.ready}isReady() — or getReady()
${cta.faqs}getFaqs()

That is why file 05 insisted on getters. A public field will not work — HTL looks for a getter.

Now, what is the model actually adapted from?

data-sly-use adapts the current resource — or the current request, if the model declares SlingHttpServletRequest.class in its adaptables. HTL works this out from the model's declaration; you do not tell it.

That connects back to file 05's adaptables question. If your model is Resource -adaptable, HTL adapts the resource the component is rendering. If it is request-adaptable, HTL adapts the request. Either way the same data-sly-use syntax works — which is why you can change a model's adaptables without touching the HTL.

Passing parameters into a model:

<sly data-sly-use.listing="${'com.energy.core.models.CategoryListingModel'
                            @ rootPath='/content/energy/products', maxItems=12}"/>

Those arrive as injectable values in the model — a @ValueMapValue or @RequestAttribute named rootPath can pick it up. Useful when the same model needs different inputs in different places.

Two other things data-sly-use can load, worth knowing because interviewers ask what else it does:

<!-- Another HTL file, for its templates -->
<sly data-sly-use.lib="/apps/energy/components/templates.html"/>

<!-- A JavaScript Use-object (the JS Use-API) -->
<sly data-sly-use.logic="logic.js"/>

The JS Use-API exists but is rare in modern projects — server-side JavaScript in AEM is generally discouraged in favour of Sling Models, and saying so shows current awareness.

The interview answer for point 17:

" data-sly-use, with the fully qualified class name. So data-sly-use.cta="com.energy.core.models.CtaModel" — where cta is a variable name I choose, not a keyword.

Then I call getters through the expression syntax: ${cta.linkText} calls getLinkText(), and ${cta.ready} calls isReady(). It follows the JavaBeans convention, which is why a Sling Model needs getters rather than public fields.

HTL adapts either the current resource or the current request depending on what the model declares in its adaptables — I don't specify that in the HTL, which is why I can change a model from resource-adaptable to request-adaptable without touching the template.

I can also pass parameters with the @ syntax if the same model needs different inputs in different places. And data-sly-use can load two other things: another HTL file, usually to reuse its data-sly-template blocks, or a JavaScript Use-object — though the JS Use-API is rare now, since Sling Models are the standard."

2.2 The <sly> element — the one you'll use constantly

Before the full list of block elements, this one deserves its own section because it appears everywhere.

The problem it solves. Every data-sly-* attribute has to sit on an element. But often you want the HTL behaviour without an element in the output.

<!-- Produces a pointless wrapper div -->
<div data-sly-use.cta="com.energy.core.models.CtaModel">
    <a href="${cta.linkUrl}">${cta.linkText}</a>
</div>

That div exists purely to hold the attribute, and it will interfere with your CSS.

<sly> is a synthetic element that never appears in the output.

<sly data-sly-use.cta="com.energy.core.models.CtaModel"/>
<a href="${cta.linkUrl}">${cta.linkText}</a>

Same behaviour, no wrapper.

<sly> is the modern approach. The older way was data-sly-unwrap, which you will still see:

<div data-sly-unwrap data-sly-use.cta="...">   <!-- old style -->

Both work; <sly> is cleaner and current.

One caution: a <sly> with data-sly-use still scopes its variable to the rest of the template, so a self-closing <sly/> followed by usage works fine — which is the pattern above.

2.3 Every block element and what it's for (syllabus point 18)

Your syllabus asks for "sightly tags and their uses." Here they are, grouped by purpose.

Group 1 — Getting data

Block elementPurpose
data-sly-useInstantiate a Sling Model, another HTL file, or a JS object
data-sly-setDefine a variable for reuse

Group 2 — Controlling output

Block elementPurpose
data-sly-testRender this element only if the expression is truthy
data-sly-listRepeat the element's children
data-sly-repeatRepeat the element itself
data-sly-unwrapRemove the element, keep its content

Group 3 — Including other things

Block elementPurpose
data-sly-resourceInclude another resource
data-sly-includeInclude another script in the same resource context
data-sly-templateDefine a reusable markup block
data-sly-callCall one of those blocks

Group 4 — Modifying the element

Block elementPurpose
data-sly-elementSet the element's tag name dynamically
data-sly-attributeSet attributes dynamically
data-sly-textReplace the element's content

That is eleven, plus <sly>. Now each one properly.

2.4 data-sly-test — conditional rendering

<div class="cmp-cta" data-sly-test="${cta.ready}">
    <a href="${cta.linkUrl}">${cta.linkText}</a>
</div>

If the expression is falsy, the element and everything inside it is removed. Not hidden — not rendered at all.

What counts as falsy in HTL:

FalsyTruthy
nullAny non-empty string
Empty string ""Any non-zero number
falsetrue
0A non-empty collection
An empty collectionAny other object

Storing the result for reuse — this is the feature people forget:

<div data-sly-test.hasImage="${cta.image}">
    <img src="${cta.image}" alt="${cta.altText}"/>
</div>

<div data-sly-test="${!hasImage}">
    <!-- fallback when there's no image -->
</div>

data-sly-test.hasImage both tests and stores the result in a variable, which you can then negate. That is HTL's version of if/else, and it is worth knowing because HTL has no else.

Why you should always guard the outer element — the discipline from file 02:

<!-- BAD: an unconfigured component renders an empty div,
     which breaks flex and grid layouts and confuses authors -->
<div class="cmp-cta">
    <a href="${cta.linkUrl}">${cta.linkText}</a>
</div>

<!-- GOOD: renders nothing at all when unconfigured -->
<div class="cmp-cta" data-sly-test="${cta.ready}">
    <a href="${cta.linkUrl}">${cta.linkText}</a>
</div>

And notice ${cta.ready} is a single flag from the model rather than five separate checks in the template. That is the right split: the model decides whether it has enough to render; the template just asks.

2.5 data-sly-list and data-sly-repeat — iteration

The difference is what gets repeated.

data-sly-list repeats the element's children:

<ul data-sly-list.faq="${model.faqs}">
    <li>${faq.question}</li>
</ul>

Output: one <ul> containing several <li>.

data-sly-repeat repeats the element itself:

<li data-sly-repeat.faq="${model.faqs}">${faq.question}</li>

Output: several <li> and no wrapper.

The rule: use data-sly-list when you want the container rendered once; use data-sly-repeat when the repeated thing is the element.

The loop variables — these come up in interviews:

<ul data-sly-list.item="${model.cards}">
    <li class="${itemList.first ? 'is-first' : ''}
               ${itemList.odd ? 'is-odd' : ''}">
        ${itemList.count}. ${item.title}
    </li>
</ul>

For a loop variable named item, HTL automatically provides itemList with:

PropertyMeaning
itemList.index0-based position
itemList.count1-based position
itemList.firstIs this the first?
itemList.lastIs this the last?
itemList.middleNeither first nor last
itemList.oddIs count odd?
itemList.evenIs count even?

The naming rule: the list object is your variable name plus List. Name it .card and you get cardList. If you omit the name entirely, the defaults are item and itemList.

A performance point that connects to file 05: everything inside a loop runs once per iteration. A getter called inside data-sly-list over fifty items is called fifty times. That is exactly why expensive work belongs in @PostConstruct.

2.6 data-sly-resource versus data-sly-include

These look similar and are genuinely different. This is a common interview question.

data-sly-resource includes a resource — it changes what content is being rendered, and Sling resolves the script for that resource type.

<div data-sly-resource="${'header' @ resourceType='energy/components/header'}"></div>

data-sly-include includes a script — the resource stays the same, only the script changes.

<div data-sly-include="partials/specs-table.html"></div>

The distinction:

data-sly-resource changes the resource. data-sly-include changes the script.

When to use each: data-sly-resource for rendering a child component or another piece of content — that is what you use most of the time. data-sly-include for splitting one component's own markup across several files, where the resource context should not change.

data-sly-resource options, several of which you have already met:

<!-- Force a resource type -->
<sly data-sly-resource="${'child' @ resourceType='energy/components/cta'}"/>

<!-- No wrapper div -- the decoration control from file 02 -->
<sly data-sly-resource="${'child' @ decoration=false}"/>

<!-- Change the wrapper element, e.g. inside a <ul> -->
<sly data-sly-resource="${item.path @ decorationTagName='li'}"/>

<!-- Add a selector to the included render -->
<sly data-sly-resource="${resource.path @ addSelectors='teaser'}"/>

decoration and decorationTagName are the extra-divs answer from file 02, and being able to place them here shows the files connect.

2.7 data-sly-template and data-sly-call — reusable markup

This is HTL's version of a function, and it is how the clientlib helper from file 04 works.

Define a template:

<template data-sly-template.card="${@ card}">
    <article class="card">
        <h3>${card.title}</h3>
        <p>${card.description}</p>
    </article>
</template>

Call it:

<sly data-sly-call="${card @ card=product}"/>

Templates from another file — the important pattern:

<sly data-sly-use.lib="/apps/energy/components/templates.html"/>
<sly data-sly-call="${lib.card @ card=product}"/>

And now file 04 makes more sense. This is exactly the clientlib mechanism:

<sly data-sly-use.clientlib="core/wcm/components/commons/v1/templates/clientlib.html"/>
<sly data-sly-call="${clientlib.css @ categories='energy.product'}"/>

clientlib.html is a file full of data-sly-template blocks named css, js and all. You load the file with data-sly-use and call a template from it. Being able to explain that connection is a good moment in an interview.

Why templates matter practically: they are how you avoid duplicating markup between a server-rendered first batch and an AJAX-loaded second batch — the exact problem in file 02's Load More story.

2.8 data-sly-element, data-sly-attribute, data-sly-text

data-sly-element sets the tag name dynamically:

<sly data-sly-element="${model.headingLevel}">${model.text}</sly>

If headingLevel is "h2", that renders <h2>...</h2>.

Why this matters: heading levels should fit the page's document outline for accessibility, so authors need to choose. Without data-sly-element you would need a chain of tests for h1 through h6.

A security note: HTL restricts this to a whitelist of safe elements. You cannot inject <script> through it, which is deliberate.

data-sly-attribute sets attributes dynamically:

<a data-sly-attribute.href="${model.linkUrl}"
   data-sly-attribute.target="${model.target}">${model.linkText}</a>

And here is the behaviour that connects to file 05: if the value is null or empty, the attribute is removed entirely rather than rendered empty.

That is why file 05's CtaModel returned null from getTarget():

public String getTarget() {
    return openInNewTab ? "_blank" : null;   // null → attribute omitted
}

You get <a href="..."> rather than <a href="..." target="">. Cleaner and valid.

You can also set a whole map of attributes:

<div data-sly-attribute="${model.dataAttributes}"></div>

data-sly-text replaces the element's content:

<p data-sly-text="${model.description}">
    This placeholder text is replaced at render time.
</p>

Why it exists: the placeholder stays visible when a front-end developer opens the file directly in a browser, but is replaced when AEM renders. That is the front-end-friendliness point in practice.

2.9 data-sly-set and data-sly-unwrap

data-sly-set defines a variable:

<sly data-sly-set.heading="${model.heading || 'Related Products'}"/>
<h2>${heading}</h2>

Useful for a value used several times, so an expensive getter is called once.

data-sly-unwrap removes the element but keeps the content:

<div data-sly-unwrap data-sly-test="${model.ready}">
    <p>Content stays, the div goes.</p>
</div>

Largely superseded by <sly>, but common in older code.

2.10 The expression language

Basic access:

${model.title}
${properties.description}
${currentPage.title}

Property names containing a colon need bracket notation — this is a genuinely common trip-up:

${properties.jcr:title}         <!-- BROKEN -- the colon isn't valid syntax -->
${properties['jcr:title']}      <!-- Correct -->

Operators:

${a && b}          <!-- and -->
${a || b}          <!-- or -->
${!a}              <!-- not -->
${a == b}          <!-- comparison -->
${a > b}
${condition ? 'yes' : 'no'}     <!-- ternary -->

What is deliberately absent: no arithmetic, no method calls with arguments, no string manipulation, no variable assignment beyond data-sly-set. If you need any of those, they belong in the model. That absence is the design.

String formatting — the correct way to build a string:

${'Page {0} of {1}' @ format=[currentPage, totalPages]}

Joining an array:

${model.tags @ join=', '}

Internationalisation:

${'Read more' @ i18n}
${'Read more' @ i18n, locale=currentPage.language}
${'Downloads' @ i18n, hint='Label on the product page tab'}

The hint gives translators context, which matters on a multi-country site where the same English word translates differently by context.

Date and number formatting:

${model.publishDate @ format='dd MMMM yyyy'}
${model.price @ format='#,###.00'}

2.11 Display contexts — HTL's security model

This is the most important security concept in HTL, and it comes up in every security-conscious interview.

HTL escapes every expression automatically. But escaping is not one thing — the correct escaping depends on where the value lands.

<p>${value}</p>                     <!-- HTML escaping -->
<a href="${value}">                 <!-- URI escaping -->
<div class="${value}">              <!-- attribute escaping -->
<script>var x = "${value}";</script> <!-- JavaScript string escaping -->

HTL infers the context from position and applies the right one. That is the fundamental improvement over JSP, where you had to call the XSS API with the correct method yourself, every time.

You can override the context explicitly:

${model.richText @ context='html'}

The contexts worth knowing:

ContextUse forNotes
textElement contentDefault there
htmlRich text that should render as markupAllows a safe subset of tags
attributeAttribute valuesDefault there
uriURLs in href, srcBlocks javascript: and similar
scriptStringInside a JavaScript string literal
styleStringInside a CSS value
elementNameA tag nameWhitelisted
numberNumeric output
unsafeNo escaping at allDangerous — see below

The two you will actually use are html and uri.

context='html' for rich-text fields, because the author's markup is meant to render:

<div>${model.bodyText @ context='html'}</div>

Without it, the author sees literal <p> tags on the page — which is scenario 17 in file 02.

context='uri' for URLs, to block javascript: URLs:

<a href="${model.linkUrl @ context='uri'}">

And context='unsafe' — the one to be careful about.

It disables escaping completely. The only place we use it is the JSON-LD structured data from file 02:

<script type="application/ld+json">${model.structuredData @ context='unsafe'}</script>

And the justification matters more than the usage:

"JSON-LD must not be HTML-escaped or search engines can't parse it, so unsafe is necessary there. It's safe because the JSON was produced by Jackson, which escapes its own values — not because the content is trusted. If I'd built that string by concatenating author input, unsafe would be a genuine XSS hole. That distinction is the whole argument: unsafe is acceptable when a serialiser produced the string, and never when I assembled it myself."

Being able to state that reasoning is what separates knowing the option from understanding it.

2.12 The global objects

HTL gives you a set of objects without any data-sly-use:

ObjectWhat it is
propertiesValueMap of the current resource
pagePropertiesValueMap of the current page's jcr:content
inheritedPagePropertiesPage properties with inheritance up the tree
currentPageThe AEM Page being rendered
resourceThe current Resource
resourceResolverThe current resolver
currentStyleThe resolved policy — from file 03
wcmmodewcmmode.edit, wcmmode.preview, wcmmode.disabled
componentThe component definition
request / responseThe Sling request and response
pageManagerLook up pages

wcmmode is the one worth calling out, because it drives the authoring placeholder pattern used throughout this repository:

<sly data-sly-test="${!model.ready && wcmmode.edit}"
     data-sly-resource="${'' @ resourceType='wcm/core/components/placeholder'}"/>

An unconfigured component renders nothing on publish, but shows a clickable placeholder to authors. Small touch, and authors genuinely notice.

A caution on properties and currentPage: they are convenient, and using them for anything beyond trivial display drags logic back into the template. If you find yourself writing conditionals over properties, that decision belongs in the model.


3. Internal Working

3.1 How HTL actually runs

graph TD A["Request for a page"] --> B["Sling resolves the resource type<br/>and finds the HTL script"] B --> C{"Is the compiled<br/>version cached?"} C -->|Yes| E["Execute the compiled servlet"] C -->|No| D["HTL compiler:<br/>parse → generate Java → compile"] D --> E E --> F["data-sly-use → adapt to the Sling Model"] F --> G["Evaluate expressions,<br/>apply contextual escaping"] G --> H["HTML output"]

Two things worth taking from this:

HTL is compiled, not interpreted. It becomes a Java servlet, which is then cached. So the performance is Java performance, not scripting-language performance.

Which means HTL has compilation errors, and they appear in error.log at first render — not at deploy time. A malformed expression will deploy successfully and fail the first time someone loads the page. That is worth knowing, because it explains why a broken template gets through a build.

3.2 How data-sly-use resolves a model

sequenceDiagram participant H as HTL participant S as Sling Models participant R as Resource / Request H->>S: data-sly-use.cta = "com.energy...CtaModel" S->>S: read the model's @Model adaptables alt adaptables = Resource.class S->>R: adapt the CURRENT RESOURCE else adaptables = SlingHttpServletRequest.class S->>R: adapt the CURRENT REQUEST end R-->>S: model instance (or NULL) S-->>H: bind to the variable "cta" H->>H: ${cta.linkText} → getLinkText()

The critical branch is in the middle. HTL reads the model's adaptables and adapts accordingly. You never specify it in the template — which is why changing a model's adaptables requires no HTL change.

And note the "or NULL". If adaptation fails, data-sly-use binds nothing, and every ${cta.something} renders empty. No error, no exception, just a blank component. That is why "my component renders nothing" is usually a Sling Model problem rather than an HTL problem, and why file 05's five-cause checklist is the thing to reach for.

A quick diagnostic: output the object itself.

${cta}

An object reference means the model resolved and the problem is in a getter. Nothing at all means adaptation returned null.

3.3 How contextual escaping is chosen

graph TD A["An expression ${value}"] --> B{"Where does it sit?"} B -->|"Element content"| C["HTML escaping"] B -->|"An attribute"| D["Attribute escaping"] B -->|"href / src"| E["URI escaping"] B -->|"Inside script"| F["JS string escaping"] B -->|"Inside style"| G["CSS escaping"] C --> H{"Did you specify<br/>@ context?"} D --> H E --> H F --> H G --> H H -->|No| I["Use the inferred context"] H -->|Yes| J["Use what you asked for"]

The key insight: the default is inferred from position and is almost always correct. You only specify a context when you deliberately want something other than the safe default — rich text that should render as markup, or serialised JSON that must not be escaped.

Which reframes the risk: in JSP, forgetting to escape was a vulnerability. In HTL, you have to actively override the safe default to create one. That inversion is the security story.


8. Coding Examples

8.1 The simple CTA — every element explained

<!--/* Load the model. On <sly> so there's no wrapper div. */-->
<sly data-sly-use.cta="com.energy.core.models.CtaModel"/>

<!--/* Guard the outer element: an unconfigured component renders
       NOTHING rather than an empty <a> that breaks the layout.
       The model exposes ONE flag rather than the template checking
       several fields -- that decision belongs in Java. */-->
<a data-sly-test="${cta.ready}"
   class="cmp-cta cmp-cta--${cta.style}"

   <!--/* context='uri' blocks javascript: URLs. Not the default
          in an href, so it has to be explicit. */-->
   href="${cta.linkUrl @ context='uri'}"

   <!--/* The model returns NULL for these when not applicable,
          and data-sly-attribute omits the attribute entirely
          rather than rendering target="" -- valid markup for free. */-->
   data-sly-attribute.target="${cta.target}"
   data-sly-attribute.rel="${cta.rel}">

    <span class="cmp-cta__text">${cta.linkText}</span>

    <!--/* Decorative icon: aria-hidden so a screen reader doesn't
           announce "right arrow" after every single link. */-->
    <span class="cmp-cta__icon" aria-hidden="true"
          data-sly-test="${cta.style == 'arrow'}">&rarr;</span>
</a>

<!--/* Authoring placeholder: visible to authors in edit mode only,
       so they get something clickable instead of nothing at all.
       Renders nothing on publish. */-->
<sly data-sly-test="${!cta.ready && wcmmode.edit}"
     data-sly-resource="${'' @ resourceType='wcm/core/components/placeholder'}"/>

Note the comment syntax: <!--/* ... */--> is an HTL comment and is stripped from the output. A plain <!-- ... --> HTML comment is sent to the browser. Using the wrong one leaks your internal notes to every visitor — a small thing that occasionally shows up in a security review.

8.2 The FAQ accordion — lists, loop variables, contexts and ARIA

<sly data-sly-use.faq="com.energy.core.models.FaqModel"/>

<section class="cmp-faq" data-sly-test="${faq.ready}">

    <!--/* data-sly-element sets the tag name from the dialog, so the
           heading level fits the page's document outline. Without it
           we'd need a chain of tests for h2 through h6. HTL restricts
           this to a whitelist, so you can't inject <script>. */-->
    <sly data-sly-element="${faq.headingLevel}" data-sly-test="${faq.heading}">
        <span class="cmp-faq__heading">${faq.heading}</span>
    </sly>

    <!--/* data-sly-list repeats the CHILDREN, so we get one <dl>
           containing many <dt>/<dd> pairs. data-sly-repeat would
           give us many <dl> elements instead. */-->
    <dl class="cmp-faq__list">
        <sly data-sly-list.item="${faq.faqs}">

            <dt class="cmp-faq__question">
                <!--/* A real <button>, not a div with a click handler --
                       keyboard focus and activation come for free. */-->
                <button class="cmp-faq__button"
                        type="button"
                        id="${item.buttonId}"
                        aria-expanded="false"
                        aria-controls="${item.panelId}">
                    <span>${item.question}</span>

                    <!--/* itemList is named after the loop variable
                           plus "List". Used here to open the first
                           panel by default. */-->
                    <span class="cmp-faq__indicator"
                          aria-hidden="true"
                          data-sly-attribute.data-first="${itemList.first}"></span>
                </button>
            </dt>

            <dd class="cmp-faq__answer"
                id="${item.panelId}"
                role="region"
                aria-labelledby="${item.buttonId}"
                hidden>
                <!--/* context='html' because this IS authored rich text
                       and is meant to render as markup. It still strips
                       dangerous constructs -- it allows a safe subset,
                       it doesn't disable escaping. */-->
                ${item.answer @ context='html'}
            </dd>

        </sly>
    </dl>
</section>

<!--/* JSON-LD for search engines. context='unsafe' is REQUIRED here,
       because JSON-LD must not be HTML-escaped or crawlers can't parse
       it. It is SAFE only because the model built this string with
       Jackson, which escapes its own values. Hand-concatenated JSON
       here would be a genuine XSS hole. */-->
<script type="application/ld+json"
        data-sly-test="${faq.structuredData}">${faq.structuredData @ context='unsafe'}</script>

<sly data-sly-test="${!faq.ready && wcmmode.edit}"
     data-sly-resource="${'' @ resourceType='wcm/core/components/placeholder'}"/>

8.3 Templates — one markup definition, two render paths

The template file — /apps/energy/components/templates/card.html:

<!--/* A reusable markup block. The ${@ card, showBadge} syntax
       declares the parameters it accepts. */-->
<template data-sly-template.productCard="${@ card, showBadge}">
    <article class="cmp-card">
        <a class="cmp-card__link" href="${card.url @ context='uri'}">

            <img class="cmp-card__image"
                 data-sly-test="${card.imageUrl}"
                 src="${card.imageUrl @ context='uri'}"
                 alt="${card.imageAlt}"
                 loading="lazy"/>

            <h3 class="cmp-card__title">${card.title}</h3>
            <p class="cmp-card__description">${card.description}</p>

            <span class="cmp-card__badge"
                  data-sly-test="${showBadge && card.isNew}">
                ${'New' @ i18n}
            </span>
        </a>
    </article>
</template>

Used by the component for the first batch:

<sly data-sly-use.listing="com.energy.core.models.CategoryListingModel"/>
<sly data-sly-use.cards="/apps/energy/components/templates/card.html"/>

<div class="cmp-listing" data-sly-test="${listing.ready}"
     data-load-more-url="${listing.loadMoreUrl @ context='uri'}">

    <div class="cmp-listing__grid" data-listing-grid>
        <sly data-sly-list.card="${listing.cards}">
            <sly data-sly-call="${cards.productCard @ card=card, showBadge=true}"/>
        </sly>
    </div>

    <button class="cmp-listing__more" type="button"
            data-sly-test="${listing.hasMore}" data-load-more>
        ${'Load more' @ i18n}
    </button>

    <!--/* Announces new results to screen readers when the grid updates.
           Without this, a screen reader user gets no feedback at all. */-->
    <div class="sr-only" role="status" aria-live="polite" data-listing-status></div>
</div>

And the same template for the AJAX batches — cardsfragment.html, which the servlet from file 07 includes:

<sly data-sly-use.listing="com.energy.core.models.CategoryListingModel"/>
<sly data-sly-use.cards="/apps/energy/components/templates/card.html"/>

<!--/* SAME template as the first batch. That is the whole point --
       two separate templates drifted apart and the grid visibly
       broke at the batch boundary. */-->
<sly data-sly-list.card="${listing.nextBatch}">
    <sly data-sly-call="${cards.productCard @ card=card, showBadge=true}"/>
</sly>

This is the payoff of the servlet decision in file 07 — returning rendered HTML rather than JSON is what lets both paths share one template.

8.4 The page component — global objects, clientlibs, wcmmode

<!DOCTYPE html>
<html lang="${currentPage.language}">
<head>
    <meta charset="utf-8"/>
    <meta name="viewport" content="width=device-width, initial-scale=1"/>

    <!--/* pageProperties reads the current page's jcr:content.
           Bracket notation is REQUIRED for names with a colon --
           ${pageProperties.jcr:title} is a syntax error. */-->
    <title>${pageProperties['jcr:title'] || currentPage.title}</title>

    <meta name="description"
          data-sly-attribute.content="${pageProperties['jcr:description']}"/>

    <sly data-sly-use.page="com.energy.core.models.PageModel"/>

    <!--/* The clientlib helper is itself a data-sly-template file --
           loaded with data-sly-use, invoked with data-sly-call. */-->
    <sly data-sly-use.clientlib="core/wcm/components/commons/v1/templates/clientlib.html"/>

    <!--/* CSS in the head so the page never flashes unstyled.
           Categories come from the PAGE POLICY, not hardcoded --
           that is how product and news pages ship different bundles
           from this one page component. */-->
    <sly data-sly-call="${clientlib.css @ categories=page.clientLibCategories}"/>
    <sly data-sly-call="${clientlib.css @ categories='energy.print', media='print'}"/>
</head>

<body class="${page.bodyClasses}">

    <sly data-sly-resource="${'header' @ resourceType='energy/components/header'}"/>

    <!--/* decoration=false: no wrapper div around the main container */-->
    <main data-sly-resource="${'root' @ decoration=false}"></main>

    <sly data-sly-resource="${'footer' @ resourceType='energy/components/footer'}"/>

    <!--/* Author-only helper, never sent to visitors */-->
    <div class="author-hint" data-sly-test="${wcmmode.edit}">
        ${'Template' @ i18n}: ${currentPage.template.title}
    </div>

    <!--/* JS at the end of the body so it never blocks parsing */-->
    <sly data-sly-call="${clientlib.js @ categories=page.clientLibCategories}"/>
    <sly data-sly-call="${clientlib.js @ categories='energy.analytics', async=true}"/>

</body>
</html>

8.5 Common patterns worth memorising

<!--/* If / else -- HTL has no else, so store the test and negate it */-->
<sly data-sly-test.hasImage="${model.imageUrl}">
    <img src="${model.imageUrl @ context='uri'}" alt="${model.altText}"/>
</sly>
<div class="placeholder" data-sly-test="${!hasImage}"></div>

<!--/* Default value */-->
<sly data-sly-set.heading="${model.heading || 'Related Products'}"/>
<h2>${heading}</h2>

<!--/* Build a string -- no + operator in HTL */-->
${'Showing {0} of {1} products' @ format=[model.shown, model.total]}

<!--/* Join an array */-->
${model.applications @ join=', '}

<!--/* Translate, with a hint for translators */-->
${'Downloads' @ i18n, hint='Tab label on the product detail page'}

<!--/* Format a date */-->
${model.publishDate @ format='dd MMMM yyyy'}

<!--/* Alternating rows */-->
<li data-sly-repeat.row="${model.rows}"
    class="row ${rowList.odd ? 'row--odd' : 'row--even'}">
    ${rowList.count}. ${row.label}
</li>

<!--/* Valid HTML inside a list: the wrapper becomes an <li>, not a <div> */-->
<ul>
    <sly data-sly-list.item="${model.items}">
        <sly data-sly-resource="${item.path @ decorationTagName='li'}"/>
    </sly>
</ul>

<!--/* HTL comment -- stripped from the output */-->
<!--/* This never reaches the browser */-->

<!--/* HTML comment -- SENT to the browser. Don't put notes here. */-->
<!-- This is visible in view-source -->

9. Common Mistakes

The mistakeWhat actually happensThe fix
Business logic in HTLUntestable, unreviewable — the thing HTL exists to preventMove it to the Sling Model
Missing context='html' on rich textVisitors see literal <p> and <strong> tagsAdd it — but only for genuine rich text
Adding context='html' by reflexA plain-text field becomes an injection pointPer-field decision, not a habit
context='unsafe' casuallyGenuine XSS holeOnly for serialiser-produced output, with a comment
No data-sly-test guardUnconfigured components render empty divs that break layoutsGuard the outer element with one model flag
Several field checks in the templateLogic creeping back into the viewOne isReady() on the model
${properties.jcr:title}Syntax error — the colon isn't valid${properties['jcr:title']}
Not using <sly>Pointless wrapper divs<sly> for any construct without markup
HTML comments for internal notesSent to the browser, visible in view-sourceHTL comments <!--/* */-->
Expensive getters called in a loopRuns per iteration — potentially hundreds of times@PostConstruct in the model
data-sly-repeat where data-sly-list was meantDuplicated containers instead of duplicated itemsList repeats children, repeat repeats the element
Duplicating markup for AJAX renderingThe two drift and break at the boundarydata-sly-template, called from both paths
Divs with click handlers instead of buttonsNot keyboard accessibleReal <button> and <a>
Hardcoded UI stringsCan't be translated@ i18n
Hardcoded clientlib categories in the page componentEvery page type gets the same bundleRead them from the page policy
decoration=false everywhereComponents become hard to select in the editordecorationTagName where possible

10. Best Practices

On the split. HTL is presentation only. If you are writing a condition over more than one field, that decision belongs in the model as a single flag.

On guarding. Always guard the outer element with data-sly-test, and add an edit-mode placeholder so authors get something clickable while visitors get nothing.

On markup. <sly> for any construct that should not produce an element. decorationTagName rather than decoration=false where the element matters. Semantic elements throughout.

On escaping. Trust the default. Only add a context when you deliberately need something other than safe — html for genuine rich text, uri for URLs. Treat unsafe as requiring a written justification in a comment.

On performance. Assume everything in a loop runs per iteration. Store a repeated value with data-sly-set. Keep the real work in the model's @PostConstruct.

On reuse. data-sly-template for any markup rendered from more than one place — especially anything rendered both server-side and via AJAX.

On accessibility. Authorable heading levels via data-sly-element. aria-* through data-sly-attribute so empty values omit the attribute. Real buttons and links. An aria-live region for anything that updates.

On i18n. Every user-visible literal gets @ i18n, with a hint where the word is ambiguous.


11. Debugging Tips

Split the problem first, the same move as every other file: is the data missing, or is the rendering wrong?

${model}

An object reference means the model adapted and the problem is in a getter or the markup. Nothing at all means adaptation returned null — which is a Sling Model problem, and file 05's five-cause checklist applies.

HTL compilation errors appear at first render, not at deploy. HTL is compiled into a Java servlet the first time a page uses it, so a malformed expression deploys successfully and fails when someone loads the page. The error is in error.log and usually names the file and line. This surprises people who expect a build to catch it.

The Developer layer in the page editor shows which script actually rendered each component, along with its resource type. That answers "is my HTL even being used" in one look — useful when an inherited script from a sling:resourceSuperType is winning and you did not realise.

When the escaping looks wrong: view source rather than the rendered page. If you see &lt;p&gt; you are missing context='html'. If you see raw tags where you expected escaping, someone has used unsafe.

When a component renders nothing: check the data-sly-test guard first — it may be working exactly as designed because the model says it is not ready. Temporarily removing the guard tells you which.

ToolAnswers
${model} in HTLDid the model adapt at all
error.logHTL compilation errors, at first render
Developer layer in the editorWhich script rendered this component
/system/console/status-adaptersIs the Sling Model registered
View sourceWhether escaping is the problem
?debugClientLibs=trueClientlib inclusion (file 04)

12. Performance Optimization

The loop multiplication is the main one. Everything inside data-sly-list or data-sly-repeat runs once per iteration. A getter that does a repository lookup, inside a fifty-item loop, is fifty lookups. The fix is in the model — @PostConstruct — but the diagnosis is in the template, because that is where you can see how many times a getter is actually reached.

Store repeated values with data-sly-set rather than calling the same getter several times.

data-sly-resource has real cost. Each include is a resource resolution and a script execution. In a loop over many items that adds up, which is one more reason to bound list sizes in the model.

Keep the guard cheap. data-sly-test="${model.ready}" should be a boolean field set in @PostConstruct, not a getter that does work.

Clientlib splitting is the biggest front-end lever, and it lives in the page policy rather than the HTL — file 04.

HTL itself is fast. It compiles to a Java servlet and is cached, so the template language is rarely the bottleneck. When a page is slow it is almost always the model, a query, or an external call — not the HTL.


15. Comparison Tables

HTL vs JSP

HTLJSP
Logic in the viewImpossibleScriptlets allowed
XSS escapingAutomatic, contextualManual XSS API calls
Valid HTML fileYesNo
Front-end friendlyYesNo
CompiledYes, to a servletYes
StatusCurrentLegacy

data-sly-list vs data-sly-repeat

data-sly-listdata-sly-repeat
RepeatsThe element's childrenThe element itself
ContainerRendered onceNot rendered
Use on<ul> with <li> insideThe <li> itself

data-sly-resource vs data-sly-include

data-sly-resourcedata-sly-include
ChangesThe resourceThe script
Script resolutionBy the new resource's typeYou name the file
Use forChild components, other contentSplitting your own markup
OptionsresourceType, decoration, decorationTagName, addSelectorsPath only

Display contexts

ContextEscapes forTypical use
textHTMLElement content (default)
htmlAllows safe tagsRich text
attributeAttribute valuesAttributes (default)
uriURLs, blocks javascript:href, src
scriptStringJS string literalsInside <script>
styleStringCSS valuesInside <style>
elementNameWhitelisted tagsdata-sly-element
unsafeNothingSerialiser-produced JSON-LD only

<sly> vs data-sly-unwrap

<sly>data-sly-unwrap
What it isA synthetic elementAn attribute on a real element
In the outputNeverThe element is removed
StatusCurrentOlder style

Where logic goes

ConcernBelongs in
Reading a propertyHTL (${model.title})
Deciding whether to renderThe model — expose isReady()
Formatting a dateHTL (@ format) or the model
Any calculationThe model
Combining several fieldsThe model
Translating a labelHTL (@ i18n)

Next topic

09-Workflows — your syllabus point 19: what a workflow is, the model/process/launcher structure, writing a custom process step, and how workflows differ from schedulers and Sling Jobs. Your syllabus note says "say you haven't done it but you know it" — so this file is built to let you answer confidently and honestly at the same time.


Topic 08 of the AEM Interview Preparation repository. Teaching style, energy-sector project domain.