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.
| Annotation | Target | Purpose |
|---|---|---|
JsonLdType | Class | Required. 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. |
JsonLdContext | Class | Optional. Sets the @context value. If you do not add this annotation, the context defaults to http://schema.org. |
JsonLdNode | Method | Marks 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 callsJsonLd#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(includingString),Character,Number, orBoolean, 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 aMapwith its own@typekey, 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("")3public class AggregatePageSchemaData extends AbstractSchema<Recordable> {45private final Object primarySchema;67private final WebImageAsset primaryImage;89// constructor omitted1011@JsonLdNode("@graph")12public List<Object> getGraph() {13return Arrays.asList(14primarySchema,15new OrganizationSchemaData(getMainContent(), getSite()),16new WebsiteSchemaData(getMainContent(), getSite()),17new WebPageSchemaData(getMainContent(), getSite()),18Optional.ofNullable(primaryImage)19.map(image -> new WebImageSchemaData(20image,21getSite(),22WebImageSchemaData.MAIN_IMAGE_CROPPED_SIZE,23getContentRef(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:
primarySchemais the current asset's own schema object, passed in when the page view model constructs the root object. For example, an article carries anArticle-type schema here.OrganizationSchemaData,WebsiteSchemaData, andWebPageSchemaDataare 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")2public abstract class AbstractFaqModuleViewModel<M> extends ViewModel<M> implements FaqModuleView {34// ...56@Override7public CharSequence getJsonLinkedData() {8if (!faqModule.isEnableJsonLd()) {9return null;10}1112return Optional.ofNullable(JsonLd.createHtmlScriptBody(this))13.map(RawHtml::of)14.orElse(null);15}1617@JsonLdNode("mainEntity")18public Iterable<? extends QuestionSchemaViewModel> getQuestionData() {19return 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")2public class QuestionSchemaViewModel extends ViewModel<FaqQuestion> implements QuestionSchemaView {34private 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");78@JsonLdNode("name")9public CharSequence getQuestion() {10return model.getQuestion();11}1213@JsonLdNode("acceptedAnswer")14public Map<String, Object> getAnswerData() {15String rawAnswer = model.getAnswer();16String sanitizedAnswer = Jsoup.clean(rawAnswer, ALLOWED_TAGS);1718return Map.of(19"@type",20"Answer",21"text",22sanitizedAnswer);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.
- Add the field to the module's JSON data file, as shown above.
- Give the module's view model a
JsonLdType. - Add
JsonLdNodegetters for the values the JSON-LD document should expose. - Override a getter for the field, such as
getJsonLinkedData(), that callsJsonLd#createHtmlScriptBody(Object)on the view model itself and wraps the result inRawHtml, as shown above. - Add the matching conditional block to the module's front-end template.