> 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.

# 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](https://nhanbe.id.vn/aem/articles/seo-ld-json.md). 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.

```json
{
    "@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.

```mermaid
flowchart TD
  A[NewsArticle] --> B[mainEntityOfPage: WebPage]
  A --> C[author: Organization]
  A --> D[publisher: Organization]
  D --> E[logo: ImageObject]
```

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.

```java
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`.

```java
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.

```java
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.

```java
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`.

```java
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.

```java
// 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.
:::

## Format ngày ISO 8601 có timezone

Google yêu cầu `datePublished`/`dateModified` đúng ISO 8601 kèm offset. Property AEM lưu kiểu `Calendar`, phải convert.

```java
// 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")`.

| Property AEM        | Y nghia                   | Map sang                   |
| ------------------- | ------------------------- | -------------------------- |
| `cq:lastReplicated` | Lan publish gan nhat      | `datePublished`            |
| `cq:lastModified`   | Lan sua noi dung gan nhat | `dateModified`             |
| `jcr:created`       | Ngay tao node             | Fallback cho datePublished |

## 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.

```java
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.

```java
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`.

```java
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.

```java
@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`.

```html
<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`.

```json
{
  "@context": "https://schema.org",
  "@graph": [
    { "@type": "NewsArticle", "headline": "..." },
    { "@type": "BreadcrumbList", "itemListElement": [] }
  ]
}
```

## Lỗi thường gặp

| Trieu chung                                  | Nguyen nhan                           | Cach sua                                          |
| -------------------------------------------- | ------------------------------------- | ------------------------------------------------- |
| `&` bien thanh `\u0026` trong JSON           | Gson mac dinh bat HTML escaping       | `new GsonBuilder().disableHtmlEscaping()`         |
| Object con thanh `{}` rong                   | Quen getter hoac field null toan bo   | Set du field, kiem tra null nguon                 |
| `image` la path tuong doi `/content/dam/...` | Khong externalize                     | Dung `externalizer.publishLink`                   |
| `datePublished` sai dinh dang                | In thang Calendar.toString()          | Convert sang ISO 8601 pattern `XXX`               |
| Field null van xuat hien la `null`           | Hiem, do cau hinh serializeNulls      | Khong goi `serializeNulls`, Gson mac dinh bo null |
| Validator bao thieu `@type`                  | Field `type` bi static khong khoi tao | Khoi tao `private final String type = "..."`      |

:::tip
Sau khi publish, dán URL vào [Rich Results Test](https://search.google.com/test/rich-results) và [Schema Markup Validator](https://validator.schema.org/). Kiểm tra từng tầng lồng: validator báo rõ object con nào thiếu field bắt buộc, ví dụ `publisher.logo` thiếu `url`.
:::
