Guide 03 · Developer docs

The theme repo and the asset pipeline

Everything a developer changes on a Core dna site lives in its theme repository. This guide covers the repository and its build, printing records in templates, stylesheets and client-side code, and the parts of a site around the main content.

L1–L2
Unit 05 · Around the main content

Canonical URLs and structured data

Where a record's canonical URL comes from, the resolution order that respects the editor's Canonical URL field, and how to build JSON-LD structured data from the record and its SEO settings.

Who this is for. Developers writing the head of a site's templates. After this page you can print a canonical link for every kind of record and add structured data that changes when the content does.

The SEO settings a template can read

Every versioned record (page, blog post, book page, FAQ page and question, product and category description) has SEO settings, reached with getSeoSettings():

MethodReturns
getTitle()The SEO title
getDescription()The meta description
getKeywords()The meta keywords
getCanonicalUrl()The Canonical URL field
getRobots()The robots key: Robots and the sitemap
getShareImage()The share image

Where the canonical URL comes from

The admin describes the Canonical URL field as the "URL of a page that has the same content and should be regarded as the original page by search engines". Resolve the canonical in this order:

  1. the Canonical URL an editor set on the record, when it is not empty;
  2. otherwise the record's own path, getFullPath(), which already reflects a custom URL when the record has one, with the site's scheme and hostname in front.

Build the fallback from the record rather than from the request, so the canonical is the same however a visitor reached the page. Put the resolution in one include that takes the record:

Template code templates/includes/frontend/canonical.html
{$siteUrl = 'https://'|cat:$_currentSite->getPrimaryEnvironmentPrimaryHostname()}
{$seoSettings = $record->getSeoSettings()}
{$cmsCanonical = $seoSettings->getCanonicalUrl()}
{if $cmsCanonical neq ""}
    {$canonicalUrl = $cmsCanonical}
{else}
    {$canonicalUrl = $siteUrl|cat:$record->getFullPath()}
{/if}
<link rel="canonical" href="{$canonicalUrl}">
{% set siteUrl = 'https://' ~ _currentSite.getPrimaryEnvironmentPrimaryHostname() %}
{% set seoSettings = record.getSeoSettings() %}
{% set cmsCanonical = seoSettings.getCanonicalUrl() %}
{% set canonicalUrl = cmsCanonical != "" ? cmsCanonical : (siteUrl ~ record.getFullPath()) %}
<link rel="canonical" href="{{ canonicalUrl }}">

Test the editor's value with neq "" (Twig != ""), not with isset(): an empty string counts as set, and isset() would print href="". The hostname comes from getPrimaryEnvironmentPrimaryHostname(), so a staging copy points its canonical at the live site.

On every kind of record

A canonical tag in the page template covers pages only. Blog posts, products, FAQ and book pages render through their own templates, each with its own head, so call the include from each of them with the record it shows: {include 'includes/frontend/canonical.html' record=$post} in Smarty, {% include 'includes/frontend/canonical.html' with { record: post } %} in Twig. Then check the page source of one record of each kind.

Structured data from the record

Structured data is a JSON-LD block that describes the page to search engines. Build it from the record and its SEO settings rather than typing values into a template, so it changes when an editor changes the content. Build the whole structure as an array and encode it once:

Template code templates/blogs/posts/show.html
{$seoSettings = $post->getSeoSettings()}
{$postUrl = 'https://'|cat:$_currentSite->getPrimaryEnvironmentPrimaryHostname()|cat:$post->getFullPath()}
{$articleLd = [
    '@context' => 'https://schema.org',
    '@type' => 'Article',
    'headline' => $post->getName(),
    'description' => $seoSettings->getDescription(),
    'url' => $postUrl
]}
<script type="application/ld+json" nonce="{$_inlineScriptNonce}">{json_encode($articleLd, 8)}</script>
{% set seoSettings = post.getSeoSettings() %}
{% set postUrl = 'https://' ~ _currentSite.getPrimaryEnvironmentPrimaryHostname() ~ post.getFullPath() %}
{% set articleLd = {
    '@context': 'https://schema.org',
    '@type': 'Article',
    'headline': post.getName(),
    'description': seoSettings.getDescription(),
    'url': postUrl
} %}
<script type="application/ld+json" nonce="{{ _inlineScriptNonce }}">{{ articleLd|json_encode(8) }}</script>

Three details keep the block valid:

  • Encode every value. Every value passes through the JSON encoder, never into a hand-quoted string, so a quotation mark or a line break in CMS text cannot break the block. For one or two values, encoding each one works too: "headline": {$post->getName()|json_encode}.
  • Escape tags. The flag 8 is JSON_HEX_TAG: it encodes < and >, so CMS text cannot close the <script> element early.
  • Add the nonce. The house practice is to give every inline <script> the CSP nonce, JSON-LD included: The CSP nonce.

More than one node

When a page describes several things, such as the organisation and the article, list them under '@graph' in the same array and encode it once, the same way. Organisation details that appear on every page, such as the name, address and phone number, belong in site data, where an administrator can correct them without a deploy (Site data and feature flags).

Go deeper

Was this page helpful?

Your answer goes to the docs team.