Skip to main content

JSON-LD

Brightspot can render JSON-LD structured data on a page so search engines can understand the content on that page. This is the developer-facing side of the JSON-LD feature described in the JSON-LD section of the SEO documentation, which covers what JSON-LD is for and how an administrator turns it on. For Brightspot's broader SEO capabilities, see Search engine optimization.

This page covers the JSON-LD mechanism itself. It is annotation-driven: you mark a view model with a type, mark its getters as nodes, and Brightspot builds the JSON-LD document by reflecting over those annotations at render time.

Annotations

Three annotations, all in com.psddev.cms.view.jsonld, control how a JSON-LD document gets built.

AnnotationTargetPurpose
JsonLdTypeClassRequired. Without this annotation, JsonLd#create(Object) returns null for that object, so any class you want processed at all needs it, even a class that exists only to hold other nodes. The value normally sets the @type value of the JSON-LD document, typically a schema.org type such as WebPage or FAQPage. For a container class with no meaningful type of its own, the value can be an empty string.
JsonLdContextClassOptional. Sets the @context value. If you do not add this annotation, the context defaults to http://schema.org.
JsonLdNodeMethodMarks a getter so its return value becomes an entry in the JSON-LD document. The annotation value is the key. If you leave the value blank, the property name from the getter is used instead.

How Brightspot builds the document

JsonLd#create(Object) takes an annotated object and returns a Map representing the JSON-LD document. It reads the JsonLdType and JsonLdContext annotations on the object's class, then inspects every property using standard JavaBean introspection. For each getter annotated with JsonLdNode, it reads the return value and adds it to the document under the annotation's key.

Brightspot handles nested and repeated values without extra work on your part.

  • If a property returns another object annotated with JsonLdType, Brightspot calls JsonLd#create(Object) on that object too, and nests the result.
  • If a property returns an Iterable, Brightspot processes each item the same way and builds a list. Null items in the iterable are skipped rather than included as null entries.
  • If a property returns a plain value, such as a CharSequence (including String), Character, Number, or Boolean, Brightspot adds it as is.
  • If a property returns a Map, Brightspot adds it as is without inspecting its contents. This is how a getter can hand-assemble a node, such as a Map with its own @type key, without a separate annotated class.
  • If a property returns a blank or null value, Brightspot leaves it out of the document rather than including an empty entry.

These rules apply to property values only. The class-level @context and @type entries work differently: create(Object) always adds @type, using the JsonLdType annotation's value even when that value is an empty string, and always adds @context, using the JsonLdContext annotation's value when present or the http://schema.org default otherwise.

This happens every time create(Object) runs, including on each nested object described above, so every node processed this way, not only the outermost one, carries its own @context and @type.

JsonLd#createHtmlScriptBody(Object) calls create(Object) and converts the result to an escaped JSON string, ready to place inside a <script> tag. Because create(Object) always adds @context and @type to the object it is called on, the resulting document always has at least those two entries, and createHtmlScriptBody(Object) returns null only when the object itself is null or is not annotated with JsonLdType.

Where JSON-LD is rendered on a page

Brightspot renders JSON-LD in two places: in the page head, and optionally inside an individual module. This section covers the page head. See Rendering additional inline JSON-LD nodes on a module below for the module case.

The page head template, Page-head.hbs, includes a conditional block for a jsonLinkedData field. When that field has a value, the template renders it inside a <script type="application/ld+json"> tag in the page head.

The page view model overrides a getJsonLinkedData() getter for that field. Brightspot's starter packs provide this on an abstract base, AbstractPageViewModel, that each content type's page view model extends with its own subclass, such as TagPageViewModel or BlogPageViewModel. Other documentation on this site refers to this role generically as the page view model. The getter calls JsonLd#createHtmlScriptBody(Object) and wraps the result in com.psddev.styleguide.RawHtml, a marker type the front-end template layer recognizes and renders without HTML-escaping, the same pattern the FAQ module below uses.

The object passed to createHtmlScriptBody(Object) is not a single schema object. It is a root object whose only content is a @graph array, so the final document is a list of sibling entries rather than one typed object. Brightspot's starter packs name this root class AggregatePageSchemaData. It, and the schema classes it assembles, extend a shared AbstractSchema<T> base class that provides getMainContent() and getSite() accessors for the current asset and site, and a getContentRef(String) helper for building @id references scoped to that asset. Simplified for clarity:

1
// A container class with no meaningful type of its own, so the JsonLdType value is empty.
2
@JsonLdType("")
3
public class AggregatePageSchemaData extends AbstractSchema<Recordable> {
4
5
private final Object primarySchema;
6
7
private final WebImageAsset primaryImage;
8
9
// constructor omitted
10
11
@JsonLdNode("@graph")
12
public List<Object> getGraph() {
13
return Arrays.asList(
14
primarySchema,
15
new OrganizationSchemaData(getMainContent(), getSite()),
16
new WebsiteSchemaData(getMainContent(), getSite()),
17
new WebPageSchemaData(getMainContent(), getSite()),
18
Optional.ofNullable(primaryImage)
19
.map(image -> new WebImageSchemaData(
20
image,
21
getSite(),
22
WebImageSchemaData.MAIN_IMAGE_CROPPED_SIZE,
23
getContentRef(WebImageSchemaData.MAIN_IMAGE_ID)))
24
.orElse(null));
25
}
26
}

Since getGraph() returns a List, JsonLd#create(Object) processes each item in that list the same way it would a single object, and collects the results into the @graph array. This is the logic that determines what appears in the page-level document:

  • primarySchema is the current asset's own schema object, passed in when the page view model constructs the root object. For example, an article carries an Article-type schema here.
  • OrganizationSchemaData, WebsiteSchemaData, and WebPageSchemaData are included on every page, built from the current site and asset.
  • An image schema is included only when the asset has a primary image.

See the sample JSON-LD output for the resulting document as a search engine sees it. That sample is simplified for illustration. It does not show the root object's own empty @type value, and it does not show the @context that each nested @graph entry carries in the real document.

The organization details used by OrganizationSchemaData and WebsiteSchemaData, such as the site name, URL, and description, come from a site setting, JsonLinkedDataSiteSettings, which an administrator can configure globally or per site. See the JSON-LD configuration procedure for those steps. If this setting is not configured, Brightspot falls back to the site's name and default site URL.

Rendering additional inline JSON-LD nodes on a module

A module can render its own JSON-LD block in addition to the page-level block. The FAQ module included in Brightspot's starter packs demonstrates this pattern, and you can follow the same approach for any module that needs to describe itself with structured data.

The FAQ module's view model, AbstractFaqModuleViewModel, is annotated JsonLdType("FAQPage") and implements a getJsonLinkedData() method that calls JsonLd#createHtmlScriptBody(Object) on itself and wraps the result in RawHtml, the same pattern described above, guarded by an Enable JSON-LD? toggle on the module's Advanced tab so an editor can turn JSON-LD off for that module. Without the RawHtml wrap, the template would escape the JSON string and the script tag would carry unusable, escaped text instead of valid JSON:

1
@JsonLdType("FAQPage")
2
public abstract class AbstractFaqModuleViewModel<M> extends ViewModel<M> implements FaqModuleView {
3
4
// ...
5
6
@Override
7
public CharSequence getJsonLinkedData() {
8
if (!faqModule.isEnableJsonLd()) {
9
return null;
10
}
11
12
return Optional.ofNullable(JsonLd.createHtmlScriptBody(this))
13
.map(RawHtml::of)
14
.orElse(null);
15
}
16
17
@JsonLdNode("mainEntity")
18
public Iterable<? extends QuestionSchemaViewModel> getQuestionData() {
19
return faqModule.getItems()
20
.stream()
21
.filter(FaqQuestion.class::isInstance)
22
.map(q -> createView(QuestionSchemaViewModel.class, q))
23
.collect(Collectors.toList());
24
}
25
}

The mainEntity node returns a collection of QuestionSchemaViewModel instances, one per FAQ item. Each of those is its own separately annotated JSON-LD node, showing how nodes can nest inside each other:

1
@JsonLdType("Question")
2
public class QuestionSchemaViewModel extends ViewModel<FaqQuestion> implements QuestionSchemaView {
3
4
private static final Safelist ALLOWED_TAGS = Safelist.none()
5
.addTags("h1", "h2", "h3", "h4", "h5", "h6", "br", "ol", "ul", "li", "a", "p", "div", "b", "strong", "i", "em")
6
.addAttributes("a", "href");
7
8
@JsonLdNode("name")
9
public CharSequence getQuestion() {
10
return model.getQuestion();
11
}
12
13
@JsonLdNode("acceptedAnswer")
14
public Map<String, Object> getAnswerData() {
15
String rawAnswer = model.getAnswer();
16
String sanitizedAnswer = Jsoup.clean(rawAnswer, ALLOWED_TAGS);
17
18
return Map.of(
19
"@type",
20
"Answer",
21
"text",
22
sanitizedAnswer);
23
}
24
}

Because getQuestionData() returns a collection of annotated objects, Brightspot builds a nested Question document for each item and places the collection under mainEntity, without any manual JSON assembly.

The module's front-end template, FaqModule.hbs, renders this data with its own conditional block, separate from the page head block:

1
{{~#with jsonLinkedData}}
2
<script type="application/ld+json">{{this}}</script>{{/with~}}

That jsonLinkedData field has to exist before the template can reference it. In a Styleguide-driven theme, a module's fields come from its JSON data file, so the field also has to be declared there, as it is in FaqModule.json:

1
{
2
"_template": "/faq/FaqModule.hbs",
3
"items": "...",
4
"jsonLinkedData": ""
5
}

Declaring the field in the JSON data file makes it part of the module's generated view interface, so the view model can override a getter for it and the template can reference it. To add a similar block to a different module, complete all of the following steps.

  1. Add the field to the module's JSON data file, as shown above.
  2. Give the module's view model a JsonLdType.
  3. Add JsonLdNode getters for the values the JSON-LD document should expose.
  4. Override a getter for the field, such as getJsonLinkedData(), that calls JsonLd#createHtmlScriptBody(Object) on the view model itself and wraps the result in RawHtml, as shown above.
  5. Add the matching conditional block to the module's front-end template.

Was this page helpful?

This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.