Social Sharing and SEO Metadata

Shareable by Default

Every page rendered by the default theme (and the starter theme) ships a complete set of social-sharing tags, so a link pasted into Slack, LinkedIn, iMessage, WhatsApp, Discord, X, Mastodon, or Facebook unfurls as a rich card – with a title, a page-specific description, and a branded image – without any per-page authoring. The tags are emitted by the bundled partials/head_meta.html.jinja partial, included from each theme’s base.html.jinja <head>.

These tags are derived from data your pages already have (page.title, page.lead, page.author, page.date, page.tags, page.media, site.url, site.name). You only add metadata when you want to override a default.

The absolute_url() Helper

absolute_url() turns a root-relative path into an absolute URL using site.url, available as both a function and a filter:

{{ absolute_url(page.url) }}      {# https://example.com/blog/post/ #}
{{ page.url | absolute_url }}     {# filter form, same result #}
  • Root-relative paths gain the site origin (trailing slash on site.url trimmed).
  • Already-absolute URLs (http://, https://) pass through unchanged.
  • When site.url is empty the input is returned unchanged.

It does not depend on the request, so canonical links and og:url are identical in accent serve and static accent build output.

Resolved Card Primitives

The head-meta partial reads three computed values, resolved once per page from the same logic that feeds JSON-LD (so a page’s card and its structured data never disagree):

ValueResolution chainDrives
page.descriptionfrontmatter description -> page.lead (the lead: field, or the first paragraph when it is unset) -> site.description<meta name="description">, og:description, twitter:description
page.social_imagefrontmatter image -> first page media image -> structured_data.default_image (absolute)og:image, twitter:image
page.meta_tagsthe default card’s own description -> site.meta -> per-page meta:, each layer overriding the one beforethe open tag map (see below)

page.lead is plain text, so the description is too. Raw HTML is stripped from it whether the lead was derived from the first paragraph or written by hand, which means a page whose body opens with a layout block is described by its visible words rather than by its tags. Your frontmatter is unchanged – only the published description is stripped.

og:type is derived automatically: article for pages with a date (adding article:published_time, article:modified_time, article:author, and one article:tag per tag), otherwise website. twitter:card is summary_large_image when an image resolves, otherwise summary.

og:title and twitter:title are the page title with the site name appended (Getting Started - My Site), mirroring what a <title> tag usually carries. The site name is repeated there rather than left to og:site_name because iMessage and Discord render the title line on its own and drop the site name entirely. A page whose title already is the site name – a home page, typically – is left alone rather than doubled, and either tag can be replaced outright through the meta: map below.

og:locale is emitted only when the site language already carries a territory (en_US, de-CH, normalised to an underscore). Open Graph wants a language_TERRITORY locale, which is a different shape from the bare lang attribute site.language drives – publishing en there is invalid and gets flagged by the platform validators, so nothing is published instead. If you want one, set it explicitly:

site:
  language: en          # drives <html lang="en">
  meta:
    og:locale: "en_US"  # drives <meta property="og:locale">

Draft, review, and archived pages – and any page with a noindex: true frontmatter flag – emit <meta name="robots" content="noindex,nofollow">. Multilingual pages emit <link rel="alternate" hreflang> for each translation plus x-default.

The flag reads as page.noindex in a template. It is a typed frontmatter field rather than a custom one, so it never appears in page.custom – a theme that looks for it there finds nothing, whatever the page declared. The same flag also keeps the page out of sitemap.xml, feed.xml, llms.txt and llms-full.txt, so one line covers both the crawler directive and the discovery files. Custom error pages honour it too, in accent serve and accent build alike, which is how a 404 stays out of an index while remaining reachable at every URL that misses.

Error pages advertise no URL

An error page is the one page with no URL of its own: it answers under every address that misses, and the build writes it to 404.html. So the partial emits no <link rel="canonical"> and no og:url there – a canonical is a claim about which address a page lives at, and any answer would be one the site does not serve. Everything else on an error page resolves normally: its frontmatter title, lead, description, image and meta: block all reach the head, exactly as on any other page, in both serve and build.

The Open meta Map

All platform-specific and arbitrary tags flow through one open map, so a network that appears next year is a config line, not a code change. The guiding rule is type the container, never the platforms.

Site-wide defaults (site.meta)

# config.yaml
site:
  name: My Site
  url: https://example.com
  description: A site powered by Accent CMS.
  meta:
    twitter:site: "@mysite"
    fediverse:creator: "@author@social.example"   # any future tag is just a key

structured_data:
  default_image: /assets/social-card.png      # reused as the card fallback image
  default_image_width: 1200                   # describes *that* image
  default_image_height: 630
  default_image_alt: "My Site -- notes on things."

Describing the default card

default_image_width, default_image_height and default_image_alt belong beside the image they describe, not in site.meta. Everything in site.meta is emitted on every page, so the size of one particular picture put there is announced for every other picture too: a page with its own 1600x900 cover would ship og:image:width: 1200, and Slack, LinkedIn, Discord and iMessage size the unfurl box from the declared numbers – the preview comes out cropped or letterboxed. The alt text has the same problem in a worse form: a screen reader announces a description of an image the reader is not being shown.

Configured here, the three are attached only to pages that actually fall back to the default card. A page with its own image: publishes no dimensions, which is correct – the platforms fetch and measure it. If you know its size, declare it per page:

---
title: Release 1.0
image: /media/release/cover.png
meta:
  og:image:width: "1600"
  og:image:height: "900"
  og:image:alt: "The 1.0 release notes, open in a terminal."
---

Per-page overrides (meta: frontmatter)

---
title: Getting Started with Accent CMS
description: Install Accent in five minutes.
image: /media/getting-started/cover.png
meta:
  og:type: article
  twitter:creator: "@a_guest_author"
---

Page entries override site entries on key collision. Each tag is emitted as <meta property="..."> or <meta name="..."> inferred from its key prefix: og:, article:, fb:, product:, music:, and video: render as property=; everything else (including twitter:*) renders as name=.

How the partial loops the map

{% for tag in page.meta_tags %}
  {% if tag.property %}<meta property="{{ tag.key }}" content="{{ tag.value }}">
  {% else %}<meta name="{{ tag.key }}" content="{{ tag.value }}">{% endif %}
{% endfor %}

No template edit is ever needed to add a new tag – it is just another key in site.meta or a page’s meta: block.

Validating meta with a document model (optional)

The meta map works with no model bound – the card always renders. Binding a document model layers validation on the same carrier: a description that exceeds its max_length, or a typo’d og:type / twitter:card value, is reported at content load instead of surfacing only when a human inspects the unfurl (platforms silently truncate over-long Open Graph values).

The default theme ships a base model that types the meta object; content models inherit it with a one-line extends: base, so the schema is shared without copy-paste:

# themes/default/models/base.yaml -- the shared schema
fields:
  description:
    type: string
    constraints: { max_length: 200 }
  meta:
    type: object
    fields:
      og:type:      { type: enum, values: [website, article, profile], default: website }
      twitter:card: { type: enum, values: [summary, summary_large_image], default: summary }
# themes/default/models/docs.yaml -- inherit the whole meta schema
extends: base

The object stays open: only declared sub-fields are validated, so undeclared keys (fediverse:creator, fb:app_id, any future tag) still pass through. The model validates input; it does not resolve fallbacks or emit tags – the resolvers and this partial are unchanged. See the document-models guide’s Sharing a Social / SEO meta Schema section for the full pattern.

Validating Your Cards

After deploying with a real site.url, confirm the unfurl with the platform validators: the Facebook Sharing Debugger, the X Card Validator, and the LinkedIn Post Inspector. Because the canonical and og:url are build-safe, the same tags appear whether you serve dynamically or ship a static build.