SEO LD+JSON: xây dựng object lồng nhau cho AEM Page
Bài cơ bản đã dựng pipeline DTO -> Gson -> HTL ở seo-ld-json. Note này đi sâu vào phần khó nhất của structured data thực tế: object lồng nhau nhiều tầng (author, publisher, mainEntityOfPage, logo), mảng object (BreadcrumbList, FAQPage), URL tuyệt đối, và format ngày ISO 8601.
JSON mục tiêu
Toàn bộ bài xoay quanh việc generate đúng cấu trúc NewsArticle sau. Chú ý độ sâu: publisher.logo là tầng thứ ba.
{
"@context": "https://schema.org",
"@type": "NewsArticle",
"inLanguage": "en-GB",
"mainEntityOfPage": {
"@type": "WebPage",
"@id": "https://example.com"
},
"headline": "headline title",
"description": "description",
"image": "https://example.com/image.jpg",
"author": {
"@type": "Organization",
"name": "Author Name",
"url": "https://example.com"
},
"publisher": {
"@type": "Organization",
"name": "Publisher Name",
"logo": {
"@type": "ImageObject",
"url": "https://example.com/logo.png"
}
},
"datePublished": "2025-10-15T10:00:00+00:00",
"dateModified": "2025-10-15T10:00:00+00:00",
"articleBody": "article body"
}
Nguyên tắc map cây JSON sang DTO lồng nhau
Mỗi object con ({ }) trong JSON tương ứng một class Java riêng. Mỗi key @type/@id/@context map bằng @SerializedName vì tên có ký tự @ không hợp lệ làm field Java.
Quy tắc: một object con dùng lại nhiều nơi (như Organization) thì tách thành class top-level, dùng một lần thì để static nested class cho gọn.
DTO đầy đủ cho NewsArticle
Tách các kiểu dùng lại (Organization, ImageObject, WebPage) thành class riêng để tái sử dụng cho schema khác.
package com.myproject.core.models.seo;
import com.google.gson.annotations.SerializedName;
public class NewsArticleSchema {
@SerializedName("@context")
private final String context = "https://schema.org";
@SerializedName("@type")
private final String type = "NewsArticle";
private String inLanguage;
private WebPage mainEntityOfPage;
private String headline;
private String description;
private String image;
private Organization author;
private Organization publisher;
private String datePublished;
private String dateModified;
private String articleBody;
// Getters/Setters cho moi field
public void setInLanguage(String v) { this.inLanguage = v; }
public void setMainEntityOfPage(WebPage v) { this.mainEntityOfPage = v; }
public void setHeadline(String v) { this.headline = v; }
public void setDescription(String v) { this.description = v; }
public void setImage(String v) { this.image = v; }
public void setAuthor(Organization v) { this.author = v; }
public void setPublisher(Organization v) { this.publisher = v; }
public void setDatePublished(String v) { this.datePublished = v; }
public void setDateModified(String v) { this.dateModified = v; }
public void setArticleBody(String v) { this.articleBody = v; }
}
Class WebPage cho mainEntityOfPage. Chỉ chứa @type và @id.
package com.myproject.core.models.seo;
import com.google.gson.annotations.SerializedName;
public class WebPage {
@SerializedName("@type")
private final String type = "WebPage";
@SerializedName("@id")
private String id;
public WebPage(String id) { this.id = id; }
public void setId(String v) { this.id = v; }
}
Class Organization dùng cho cả author và publisher. logo là field optional kiểu ImageObject. Gson bỏ qua field null nên author không có logo sẽ tự động không xuất hiện trong JSON.
package com.myproject.core.models.seo;
import com.google.gson.annotations.SerializedName;
public class Organization {
@SerializedName("@type")
private final String type = "Organization";
private String name;
private String url;
private ImageObject logo;
public void setName(String v) { this.name = v; }
public void setUrl(String v) { this.url = v; }
public void setLogo(ImageObject v) { this.logo = v; }
}
Class ImageObject cho publisher.logo, tầng lồng sâu nhất.
package com.myproject.core.models.seo;
import com.google.gson.annotations.SerializedName;
public class ImageObject {
@SerializedName("@type")
private final String type = "ImageObject";
private String url;
public ImageObject(String url) { this.url = url; }
public void setUrl(String v) { this.url = v; }
}
Sling Model lắp ráp object lồng nhau
Điểm mấu chốt: lắp từ trong ra ngoài. Tạo ImageObject trước, gắn vào Organization publisher, rồi gắn publisher vào NewsArticleSchema.
package com.myproject.core.models;
import com.day.cq.commons.Externalizer;
import com.day.cq.wcm.api.Page;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.myproject.core.models.seo.*;
import org.apache.commons.lang3.StringUtils;
import org.apache.sling.api.SlingHttpServletRequest;
import org.apache.sling.api.resource.ResourceResolver;
import org.apache.sling.models.annotations.DefaultInjectionStrategy;
import org.apache.sling.models.annotations.Model;
import org.apache.sling.models.annotations.injectorspecific.OSGiService;
import org.apache.sling.models.annotations.injectorspecific.ScriptVariable;
import org.apache.sling.models.annotations.injectorspecific.SlingObject;
import javax.annotation.PostConstruct;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Calendar;
@Model(adaptables = SlingHttpServletRequest.class,
defaultInjectionStrategy = DefaultInjectionStrategy.OPTIONAL)
public class NewsArticleSeoModel {
@ScriptVariable
private Page currentPage;
@SlingObject
private ResourceResolver resourceResolver;
@OSGiService
private Externalizer externalizer;
private String jsonLdString;
@PostConstruct
protected void init() {
if (currentPage == null) {
return;
}
var props = currentPage.getProperties();
NewsArticleSchema schema = new NewsArticleSchema();
schema.setInLanguage(resolveLanguage());
schema.setHeadline(props.get("jcr:title", currentPage.getName()));
schema.setDescription(props.get("jcr:description", ""));
schema.setArticleBody(props.get("articleBody", ""));
// URL tuyet doi cua chinh page
String pageUrl = toAbsoluteUrl(currentPage.getPath());
schema.setMainEntityOfPage(new WebPage(pageUrl));
// image: chuyen DAM path sang URL tuyet doi
String imagePath = props.get("fileReference", String.class);
if (StringUtils.isNotBlank(imagePath)) {
schema.setImage(toAbsoluteUrl(imagePath));
}
// author: Organization mot tang
Organization author = new Organization();
author.setName(props.get("authorName", "My Company"));
author.setUrl(toAbsoluteUrl("/content/myproject/en"));
schema.setAuthor(author);
// publisher: Organization chua logo ImageObject (hai tang)
Organization publisher = new Organization();
publisher.setName("My Company");
publisher.setLogo(new ImageObject(toAbsoluteUrl("/content/dam/myproject/logo.png")));
schema.setPublisher(publisher);
// datePublished / dateModified theo ISO 8601
schema.setDatePublished(toIso8601(props.get("cq:lastReplicated", Calendar.class)));
schema.setDateModified(toIso8601(props.get("cq:lastModified", Calendar.class)));
// Gson: tat HTML escaping de khong bien & thanh \u0026
Gson gson = new GsonBuilder().disableHtmlEscaping().create();
this.jsonLdString = gson.toJson(schema);
}
private String resolveLanguage() {
return currentPage.getLanguage(false) != null
? currentPage.getLanguage(false).toLanguageTag()
: "en-GB";
}
private String toAbsoluteUrl(String path) {
// Externalizer them scheme + host theo cau hinh PUBLISH externalizer
return externalizer.publishLink(resourceResolver, path);
}
private String toIso8601(Calendar cal) {
if (cal == null) {
return null;
}
ZonedDateTime zdt = cal.toInstant().atZone(ZoneOffset.UTC);
return zdt.format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssXXX"));
}
public String getJsonLdString() {
return jsonLdString;
}
}
URL tuyệt đối bằng Externalizer
Schema.org yêu cầu image, @id, logo.url, author.url là URL tuyệt đối. Không bao giờ hardcode domain. Dùng OSGi Externalizer để lấy host theo môi trường.
// SAI: hardcode domain, vo dung khi doi moi truong
schema.setImage("https://example.com" + imagePath);
// DUNG: Externalizer doc config theo domain publish
schema.setImage(externalizer.publishLink(resourceResolver, imagePath));
Cấu hình tại /system/console/configMgr -> Day CQ Link Externalizer. Khai báo các domain local, author, publish. publishLink dùng domain publish.
Tip
Externalizer trả về path không có extension cho page. Với page cần .html, gọi externalizer.publishLink(resolver, currentPage.getPath() + ".html"). Với DAM asset thì giữ nguyên path gốc vì đã có extension file.
Google yêu cầu datePublished/dateModified đúng ISO 8601 kèm offset. Property AEM lưu kiểu Calendar, phải convert.
// cq:lastModified la Calendar -> "2025-10-15T10:00:00+00:00"
ZonedDateTime zdt = cal.toInstant().atZone(ZoneOffset.UTC);
String iso = zdt.format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssXXX"));
Pattern XXX cho ra +00:00. Nếu dùng giờ local thay vì UTC, đổi ZoneOffset.UTC thành ZoneId.of("Europe/London").
Mảng object lồng nhau: BreadcrumbList
Schema dạng list dùng List<T>, mỗi phần tử lại chứa object con. Gson serialize List thành mảng JSON tự động.
public class BreadcrumbListSchema {
@SerializedName("@context")
private final String context = "https://schema.org";
@SerializedName("@type")
private final String type = "BreadcrumbList";
private List<ListItem> itemListElement = new ArrayList<>();
public void addItem(ListItem item) { this.itemListElement.add(item); }
}
public class ListItem {
@SerializedName("@type")
private final String type = "ListItem";
private int position;
private String name;
private String item; // URL tuyet doi
public ListItem(int position, String name, String item) {
this.position = position;
this.name = name;
this.item = item;
}
}
Build trong model bằng cách duyệt cây page cha.
BreadcrumbListSchema breadcrumb = new BreadcrumbListSchema();
int pos = 1;
Page p = currentPage;
List<Page> trail = new ArrayList<>();
while (p != null && p.getDepth() >= 3) { // dung o site root
trail.add(0, p);
p = p.getParent();
}
for (Page page : trail) {
breadcrumb.addItem(new ListItem(pos++, page.getTitle(),
externalizer.publishLink(resourceResolver, page.getPath() + ".html")));
}
Mảng object hai tầng: FAQPage
FAQPage lồng Question, mỗi Question lồng acceptedAnswer kiểu Answer. Dữ liệu thường đến từ multifield trong dialog, đọc bằng @ChildResource.
public class FaqPageSchema {
@SerializedName("@context")
private final String context = "https://schema.org";
@SerializedName("@type")
private final String type = "FAQPage";
private List<Question> mainEntity = new ArrayList<>();
public void addQuestion(Question q) { this.mainEntity.add(q); }
}
public class Question {
@SerializedName("@type")
private final String type = "Question";
private String name; // cau hoi
private Answer acceptedAnswer; // object con
public Question(String name, String answerText) {
this.name = name;
this.acceptedAnswer = new Answer(answerText);
}
}
public class Answer {
@SerializedName("@type")
private final String type = "Answer";
private String text;
public Answer(String text) { this.text = text; }
}
Đọc multifield FAQ từ child resource và build.
@ChildResource(name = "faqItems")
private List<Resource> faqItems;
// trong @PostConstruct
FaqPageSchema faq = new FaqPageSchema();
if (faqItems != null) {
for (Resource item : faqItems) {
var vm = item.getValueMap();
faq.addQuestion(new Question(
vm.get("question", ""),
vm.get("answer", "")));
}
}
Render nhiều schema cùng lúc
Một page có thể cần NewsArticle lẫn BreadcrumbList. Mỗi schema một thẻ <script> riêng, đừng gộp vào một array trừ khi dùng @graph.
<sly data-sly-use.seo="com.myproject.core.models.NewsArticleSeoModel"></sly>
<sly data-sly-test="${seo.jsonLdString}">
<script type="application/ld+json">${seo.jsonLdString @ context='unsafe'}</script>
</sly>
<sly data-sly-test="${seo.breadcrumbJson}">
<script type="application/ld+json">${seo.breadcrumbJson @ context='unsafe'}</script>
</sly>
Muốn gộp nhiều entity trong một script thì dùng @graph.
{
"@context": "https://schema.org",
"@graph": [
{ "@type": "NewsArticle", "headline": "..." },
{ "@type": "BreadcrumbList", "itemListElement": [] }
]
}
Lỗi thường gặp