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.urltrimmed). - Already-absolute URLs (
http://,https://) pass through unchanged. - When
site.urlis 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):
| Value | Resolution chain | Drives |
|---|---|---|
page.description | frontmatter 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_image | frontmatter image -> first page media image -> structured_data.default_image (absolute) | og:image, twitter:image |
page.meta_tags | the default card’s own description -> site.meta -> per-page meta:, each layer overriding the one before | 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.
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.