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 -> auto lead -> 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_tagsmerge of site.meta + per-page meta: (page wins)the open tag map (see below)

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.

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

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.