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_tagsmerge of site.meta + per-page meta: (page wins)the 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.

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.