SEO, dates and images in themes
Three jobs used to trip up every theme: SEO tags, dates and image sizes. Core now does each one properly, and your theme calls a helper. This page explains what core does and how to adjust it.
SEO and social tags
Core adds these to every public page, built from the post, its SEO panel in the editor, and Settings → SEO:
| Group | What it prints |
|---|---|
title | <title>, from the SEO title or the title format (%title% %sep% %site%) |
description | <meta name="description">: SEO description, then the excerpt, then the site default |
canonical | <link rel="canonical">, absolute, keeping ?page=N past page one |
robots | The post's setting; noindex on search, 404 and previews |
opengraph | og:* tags, plus article:published_time, article:modified_time, article:section and article:tag on posts |
twitter | twitter:card, title, description, image |
jsonld | One JSON-LD graph: Organization, WebSite (with the search box), the page type, BlogPosting on posts, BreadcrumbList |
generator | <meta name="generator" content="Basehim CMS">, without a version |
Your theme prints none of these. Core inserts them right after <meta charset>, whether or not you call bh_head(). If an older theme still prints its own, core removes the theme's copies so crawlers see one set. A plain <title> in your header is fine: it's a fallback, and core sets its text.
The share image is chosen in this order: the post's SEO image, then its featured image, then the default in Settings → SEO.
Changing tags on one page
bh_seo() adjusts the page being rendered. The head is assembled after the template runs, so you can call it anywhere in a template:
<?php
bh_seo()->set('title', 'Pricing – Acme'); // the whole <title>, used as is
bh_seo()->set('description', 'Plans for every team.');
bh_seo()->set('image', ['url' => $base . '/uploads/2026/10/pricing.png', 'width' => 1200, 'height' => 630]);
bh_seo()->set('robots', 'noindex, follow');
bh_seo()->addJsonLd(['@type' => 'Product', 'name' => 'Acme Pro']);
bh_seo()->disable('twitter'); // no twitter:* tags on this page
?>
set() keys: title, description, canonical, robots, image (a URL, or an array with url, width, height, alt and type), og_type, og_title, og_description and twitter_card. Read a value back with bh_seo_value('title').
Changing tags on every page
Register filters in the theme's boot.php, or in an app's boot() with $this->addFilter(). Each filter receives the value and a context array. Pass 10, 2 to receive the context:
// boot.php — add a node to the home page's JSON-LD graph.
$hooks->addFilter('seo.jsonld', function (array $nodes, array $ctx): array {
if ($ctx['type'] === 'home') {
$nodes[] = ['@type' => 'SoftwareApplication', 'name' => 'Acme',
'applicationCategory' => 'DeveloperApplication'];
}
return $nodes;
}, 10, 2);
| Filter | Value |
|---|---|
seo.enabled | bool; false turns the service off for this request |
seo.groups | ['title' => 'core', 'opengraph' => 'off', …]: per group, core, off, or external (core leaves it alone) |
seo.title, seo.description, seo.canonical, seo.robots | string |
seo.image | ?array (url, width, height, alt, type) |
seo.jsonld | the list of graph nodes |
seo.head | the whole result, as a last word |
$ctx contains:
type:home,post,page,archive,author,search,404orother;template,post,terms,term,author,query;page: the page number;is_preview;seo: the values the route worked out.
A filter that throws is skipped, and the page keeps core's values.
Handing a group to the theme
Rarely, a theme must print a group itself. Say so in theme.json and core will neither print that group nor remove the theme's copy:
"seo": { "jsonld": false }
"seo": false hands every group to the theme. Prefer the filters: they keep the site owner's switches in Settings → SEO working.
Dates and times
Basehim stores every time in UTC. The site's timezone and date formats are set in Settings → General, and every helper converts on the way out. Changing the timezone moves every date on the site at once.
PHP itself runs in UTC, so this common line is wrong for most of the world:
<?= date('M j, Y', strtotime($post['published_at'])) ?> // shows the UTC date
Use the helpers instead:
<?= bh_date($post['published_at']) ?> <!-- October 10, 2026 (site format) -->
<?= bh_date($post['published_at'], 'M j') ?> <!-- Oct 10 -->
<?= bh_time($post['published_at']) ?> <!-- 1:32 pm -->
<?= bh_time_ago($comment['created_at']) ?> <!-- 5 minutes ago -->
<?= bh_time_tag($post['published_at'], 'M j, Y') ?> <!-- <time datetime="2026-10-10T13:32:00+05:00">Oct 10, 2026</time> -->
$t can be a stored Y-m-d H:i:s string (read as UTC), a string with its own offset, a Unix timestamp or a DateTimeInterface. Empty input gives ''. To store a time someone typed in the site's timezone, convert it with bh_to_utc(); for "now", use bh_now().
Apps get the same functions in PHP as App\Core\Time (Time::date(), Time::local()…). Scripts can call GET /api/v1/time, GET /api/v1/timezones and GET /api/v1/time/convert?value=…&to=local.
Featured images
Core makes several sizes of every uploaded image, set under Settings → Media:
| Size | Default |
|---|---|
thumbnail | Square crop, 150 px |
medium | Fits 300 px |
large | Fits 1024 px |
full | The original |
Print a responsive image with one call:
<?= bh_post_image($post, 'medium', [
'class' => 'card__img',
'sizes' => '(max-width: 600px) 100vw, 320px',
]) ?>
It returns an <img> with src, a srcset of the other sizes of the same shape, width and height (so the page doesn't jump), and loading="lazy". Pass 'loading' => 'eager' for the image at the top of a post. It returns '' when the post has no featured image, so you can call it unconditionally.
For just a URL, use bh_image_url($post, 'thumbnail'). It falls back to the original when a size wasn't made. bh_image_sizes($post) returns every size with its dimensions.
Images imported from other systems may have no sizes yet. Settings → Media → Regenerate thumbnails builds them in batches.
Leave a comment