Bear Blog meta image generator
A dashboard plugin that generates a meta image for your post in one click, no design tools needed.
It opens right from the post editor, pulls in your post title automatically, and lets you download a ready-to-upload PNG image to your post.1
Preview

How to use
Installation
- Copy the script below.
- Go to Customise dashboard.
- Paste it under "Dashboard footer content".
- Save. Enjoy.
Generating an image
- Open a post and click Meta image in the editor's link bar.
- The title field pre-fills from your post automatically, edit it if needed.
- Optionally fill in a blog name, tagline, domain, and pick an accent emoji.
- Watch the live preview update as you type.
- Click Download PNG. The file saves as
your-post-title-meta.pngand the window closes automatically. - Upload it in your post's
meta_image:field.
Plugin
<script>
/*
Plugin name: Meta image
Description: Generate and download a meta image for your post.
Author: Robert Birming
Author URI: https://robertbirming.com
*/
(function () {
'use strict';
const $headerContent = document.getElementById('header_content');
const $insertMedia = document.getElementById('upload-image');
if (!$headerContent || !$insertMedia) return;
const blogSlug = window.location.pathname.split('/').filter(Boolean)[0];
function getPostTitle() {
const raw = $headerContent.innerText || '';
const match = raw.match(/title:\s*(.+)/i);
return match ? match[1].trim() : '';
}
// Robeart light-mode design tokens, pinned deliberately rather than read live via
// getComputedStyle. Meta images are static, cacheable artifacts shared into contexts
// Robert doesn't control (Slack, X, iMessage, etc.), mostly light-chrome UIs, so the
// image should always render the same way regardless of the viewer's or the
// generating browser's OS color-scheme setting. Robeart's dark-mode values are
// intentionally not used here. Update these if Robeart's :root light values change.
var ROBEART_LIGHT = {
bg: '#f8f8f8',
text: '#2c2d2e',
heading: '#2c2d2e',
border: 'rgba(44, 45, 46, 0.15)', // ~= color-mix(in srgb, --text-color, transparent 85%)
fontHeading: 'Tahoma', // Robeart --font-main
fontBody: 'Verdana' // Robeart --font-secondary
};
function getTokens() {
return {
bg: ROBEART_LIGHT.bg,
text: ROBEART_LIGHT.text,
border: ROBEART_LIGHT.border,
fontHeading: ROBEART_LIGHT.fontHeading,
fontBody: ROBEART_LIGHT.fontBody
};
}
function getCustomDomain() {
const $viewBtn = document.getElementById('view-button');
if (!$viewBtn) return null;
const match = ($viewBtn.getAttribute('onclick') || '').match(/\/\/([^/'"]+)/);
return match ? match[1] : null;
}
var STORAGE_KEY = 'meta_blog_name';
function getSavedBlogName() {
try { return localStorage.getItem(STORAGE_KEY) || ''; } catch (e) { return ''; }
}
function saveBlogName(val) {
try { localStorage.setItem(STORAGE_KEY, val); } catch (e) {}
}
// --- Inject "Meta image" into the native link bar ---
const $bar = $insertMedia.parentElement;
const $sep = document.createTextNode(' | ');
const $link = document.createElement('a');
$link.href = '#';
$link.textContent = 'Meta image';
$link.addEventListener('click', function (e) {
e.preventDefault();
openModal();
});
$bar.appendChild($sep);
$bar.appendChild($link);
// --- Styles ---
const $style = document.createElement('style');
$style.textContent = `
#meta-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 9999;
display: none;
align-items: center;
justify-content: center;
padding: 20px;
box-sizing: border-box;
}
#meta-modal {
width: 100%;
max-width: 660px;
max-height: 90vh;
overflow-y: auto;
background: var(--background-color, #fff);
color: var(--text-color, #444);
border: 1px solid lightgrey;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
box-sizing: border-box;
}
#meta-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid lightgrey;
}
#meta-body {
padding: 16px;
display: flex;
flex-direction: column;
gap: 14px;
}
#meta-preview-wrap {
width: 100%;
aspect-ratio: 1200 / 630;
overflow: hidden;
border: 1px solid rgba(128, 128, 128, 0.2);
}
#meta-preview-wrap svg {
width: 100%;
height: 100%;
display: block;
}
.meta-field {
display: flex;
flex-direction: column;
gap: 4px;
}
.meta-field label {
font-size: 0.8em;
font-weight: 600;
opacity: 0.55;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.meta-field input[type="text"] {
width: 100%;
padding: 6px 8px;
border: 1px solid rgba(128, 128, 128, 0.3);
border-radius: 3px;
background: transparent;
color: inherit;
font-family: inherit;
font-size: 0.95em;
box-sizing: border-box;
}
.meta-field input[type="text"]:focus {
outline: none;
border-color: var(--link-color, #1a5cb8);
}
.meta-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
#meta-emoji-strip {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.meta-emoji-btn {
width: 36px;
height: 36px;
border: 1px solid rgba(128, 128, 128, 0.25);
border-radius: 3px;
background: rgba(128, 128, 128, 0.07);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
color: inherit;
font-family: inherit;
font-size: 1.2em;
}
.meta-emoji-btn.meta-emoji-none {
font-size: 0.75em;
width: auto;
padding: 0 8px;
}
.meta-emoji-btn:hover {
border-color: rgba(128, 128, 128, 0.45);
}
.meta-emoji-btn.active {
border-color: var(--link-color, #1a5cb8);
background: rgba(128, 128, 128, 0.14);
}
#meta-custom-emoji {
width: 64px;
height: 36px;
padding: 0 6px;
border: 1px solid rgba(128, 128, 128, 0.3);
border-radius: 3px;
background: transparent;
color: inherit;
font-family: inherit;
font-size: 0.75em;
}
#meta-custom-emoji:focus {
outline: none;
border-color: var(--link-color, #1a5cb8);
}
#meta-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
padding-top: 2px;
}
@media (prefers-color-scheme: dark) {
#meta-modal,
#meta-head {
border-color: rgba(255, 255, 255, 0.12);
}
}
`;
document.head.appendChild($style);
// --- Build modal DOM ---
const $overlay = document.createElement('div');
$overlay.id = 'meta-overlay';
const $modal = document.createElement('div');
$modal.id = 'meta-modal';
const $head = document.createElement('div');
$head.id = 'meta-head';
const $headTitle = document.createElement('strong');
$headTitle.textContent = 'Meta image';
const $closeBtn = document.createElement('button');
$closeBtn.type = 'button';
$closeBtn.textContent = 'Close';
$closeBtn.addEventListener('click', closeModal);
$head.appendChild($headTitle);
$head.appendChild($closeBtn);
const $modalBody = document.createElement('div');
$modalBody.id = 'meta-body';
const $previewWrap = document.createElement('div');
$previewWrap.id = 'meta-preview-wrap';
// Blog name field
const $blognameField = document.createElement('div');
$blognameField.className = 'meta-field';
const $blognameLabel = document.createElement('label');
$blognameLabel.textContent = 'Blog name';
const $blognameCtrl = document.createElement('input');
$blognameCtrl.type = 'text';
$blognameCtrl.id = 'meta-blogname';
$blognameCtrl.placeholder = 'Your name or blog name';
$blognameField.appendChild($blognameLabel);
$blognameField.appendChild($blognameCtrl);
// Title field
const $titleField = document.createElement('div');
$titleField.className = 'meta-field';
const $titleLabel = document.createElement('label');
$titleLabel.textContent = 'Title';
const $titleCtrl = document.createElement('input');
$titleCtrl.type = 'text';
$titleCtrl.id = 'meta-title';
$titleCtrl.placeholder = 'Post title';
$titleField.appendChild($titleLabel);
$titleField.appendChild($titleCtrl);
// Tagline + domain row
const $row = document.createElement('div');
$row.className = 'meta-row';
const $taglineField = document.createElement('div');
$taglineField.className = 'meta-field';
const $taglineLabel = document.createElement('label');
$taglineLabel.textContent = 'Tagline';
const $taglineCtrl = document.createElement('input');
$taglineCtrl.type = 'text';
$taglineCtrl.id = 'meta-tagline';
$taglineCtrl.placeholder = 'e.g. a personal blog';
$taglineField.appendChild($taglineLabel);
$taglineField.appendChild($taglineCtrl);
const $domainField = document.createElement('div');
$domainField.className = 'meta-field';
const $domainLabel = document.createElement('label');
$domainLabel.textContent = 'Domain';
const $domainCtrl = document.createElement('input');
$domainCtrl.type = 'text';
$domainCtrl.id = 'meta-domain';
$domainCtrl.placeholder = 'yourblog.com';
$domainField.appendChild($domainLabel);
$domainField.appendChild($domainCtrl);
$row.appendChild($taglineField);
$row.appendChild($domainField);
// Emoji strip
const $emojiField = document.createElement('div');
$emojiField.className = 'meta-field';
const $emojiLabel = document.createElement('label');
$emojiLabel.textContent = 'Accent graphic';
const $emojiStrip = document.createElement('div');
$emojiStrip.id = 'meta-emoji-strip';
var emojiOptions = ['', '✏️', '💬', '📚', '🎵', '🎙️', '🎬', '📷', '☕', '🌱', '❤️', '🐻'];
emojiOptions.forEach(function (em) {
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'meta-emoji-btn' + (em === '' ? ' meta-emoji-none active' : '');
btn.dataset.emoji = em;
btn.setAttribute('aria-label', em ? em + ' accent' : 'No accent graphic');
btn.textContent = em || 'None';
$emojiStrip.appendChild(btn);
});
const $customEmoji = document.createElement('input');
$customEmoji.type = 'text';
$customEmoji.id = 'meta-custom-emoji';
$customEmoji.placeholder = 'Custom';
$emojiStrip.appendChild($customEmoji);
$emojiField.appendChild($emojiLabel);
$emojiField.appendChild($emojiStrip);
const $actions = document.createElement('div');
$actions.id = 'meta-actions';
const $dlBtn = document.createElement('button');
$dlBtn.type = 'button';
$dlBtn.textContent = 'Download PNG';
$actions.appendChild($dlBtn);
$modalBody.appendChild($previewWrap);
$modalBody.appendChild($blognameField);
$modalBody.appendChild($titleField);
$modalBody.appendChild($row);
$modalBody.appendChild($emojiField);
$modalBody.appendChild($actions);
$modal.appendChild($head);
$modal.appendChild($modalBody);
$overlay.appendChild($modal);
document.body.appendChild($overlay);
$overlay.addEventListener('click', function (e) {
if (e.target === $overlay) closeModal();
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && $overlay.style.display === 'flex') closeModal();
});
// --- State ---
var selectedEmoji = '';
// --- Open / close ---
function openModal() {
$blognameCtrl.value = getSavedBlogName();
$titleCtrl.value = getPostTitle();
$domainCtrl.value = getCustomDomain() || blogSlug + '.bearblog.dev';
$taglineCtrl.value = '';
selectedEmoji = '';
$emojiStrip.querySelectorAll('.meta-emoji-btn').forEach(function (b) {
b.classList.toggle('active', b.dataset.emoji === '');
});
$customEmoji.value = '';
render();
$overlay.style.display = 'flex';
$titleCtrl.focus();
}
function closeModal() {
$overlay.style.display = 'none';
}
// --- SVG builder ---
function escX(s) {
return String(s)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
}
// Shared canvas for text-width measurement, avoids naive character-count wrapping
var $measureCanvas = document.createElement('canvas');
var measureCtx = $measureCanvas.getContext('2d');
function measureWidth(text, fontPx, fontFamily) {
measureCtx.font = '800 ' + fontPx + 'px ' + fontFamily + ', system-ui, sans-serif';
return measureCtx.measureText(text).width;
}
function wrapByWidth(text, maxWidth, fontPx, fontFamily, maxLines) {
var words = String(text).split(' ');
var lines = [];
var current = '';
words.forEach(function (w) {
var candidate = current ? current + ' ' + w : w;
if (measureWidth(candidate, fontPx, fontFamily) > maxWidth && current) {
lines.push(current);
current = w;
} else {
current = candidate;
}
});
if (current) lines.push(current);
if (lines.length > maxLines) {
lines = lines.slice(0, maxLines);
var last = lines[maxLines - 1];
while (measureWidth(last + '…', maxWidth, fontPx, fontFamily) > maxWidth && last.length > 1) {
last = last.slice(0, -1);
}
lines[maxLines - 1] = last.replace(/\s+$/, '') + '…';
}
return lines;
}
function buildSVG(title, domain, tagline, blogname, emoji, tokens) {
var W = 1200, H = 630;
var bg = tokens.bg;
var text = tokens.text;
var border = tokens.border;
var fontHeading = tokens.fontHeading;
var fontBody = tokens.fontBody;
var titleText = title || 'Untitled';
var maxWidth = W - 160; // 80px margin each side
var fontSizes = [74, 64, 56];
var lines, fs;
// Try progressively smaller sizes until the title fits in 3 lines
for (var i = 0; i < fontSizes.length; i++) {
fs = fontSizes[i];
lines = wrapByWidth(titleText, maxWidth, fs, fontHeading, 3);
if (lines.length <= (i === fontSizes.length - 1 ? 3 : 2)) break;
}
var y0 = lines.length > 2 ? 180 : (lines.length === 2 ? 200 : 232);
var lh = fs * 1.22;
var titleSVG = lines.map(function (l, i) {
return '<text x="80" y="' + (y0 + i * lh) + '"'
+ ' font-family="' + escX(fontHeading) + ', system-ui, sans-serif"'
+ ' font-size="' + fs + '"'
+ ' font-weight="700"'
+ ' fill="' + escX(text) + '">'
+ escX(l) + '</text>';
}).join('\n ');
var lastBaseline = y0 + (lines.length - 1) * lh;
var ruleY = lastBaseline + 36;
var taglineY = ruleY + 46;
// Blog name — upper right corner
var blognameSVG = blogname
? '<text x="' + (W - 80) + '" y="80"'
+ ' font-family="' + escX(fontBody) + ', system-ui, sans-serif"'
+ ' font-size="14" font-weight="700" text-anchor="end"'
+ ' fill="' + escX(text) + '" opacity="0.4"'
+ ' letter-spacing="0.05em">'
+ escX(blogname.toUpperCase()) + '</text>'
: '';
// Tagline — below rule, same area as title, wrapped to 2 lines max
var taglineFS = 28;
var taglineLH = taglineFS * 1.3;
var taglineSVG = '';
if (tagline) {
var taglineLines = wrapByWidth(tagline, maxWidth, taglineFS, fontBody, 2);
taglineSVG = taglineLines.map(function (l, i) {
return '<text x="80" y="' + (taglineY + i * taglineLH) + '"'
+ ' font-family="' + escX(fontBody) + ', system-ui, sans-serif"'
+ ' font-size="' + taglineFS + '" font-weight="400"'
+ ' fill="' + escX(text) + '" opacity="0.55">'
+ escX(l) + '</text>';
}).join('\n ');
}
// Domain — bottom left, muted
var domainSVG = domain
? '<text x="80" y="' + (H - 36) + '"'
+ ' font-family="' + escX(fontBody) + ', system-ui, sans-serif"'
+ ' font-size="16" font-weight="400"'
+ ' fill="' + escX(text) + '" opacity="0.35">'
+ escX(domain) + '</text>'
: '';
// Emoji — bottom right
var emojiSVG = emoji
? '<text x="' + (W - 80) + '" y="' + (H - 50) + '"'
+ ' font-size="150" text-anchor="end" opacity="0.18">' + emoji + '</text>'
: '';
return '<svg xmlns="http://www.w3.org/2000/svg"'
+ ' viewBox="0 0 ' + W + ' ' + H + '"'
+ ' width="' + W + '" height="' + H + '">\n'
+ ' <rect width="' + W + '" height="' + H + '" fill="' + escX(bg) + '"/>\n'
+ ' <rect x="0.5" y="0.5" width="' + (W - 1) + '" height="' + (H - 1) + '" fill="none"'
+ ' stroke="' + escX(border) + '" stroke-width="1"/>\n'
+ ' ' + blognameSVG + '\n'
+ ' ' + emojiSVG + '\n'
+ ' ' + titleSVG + '\n'
+ ' <line x1="80" y1="' + ruleY + '" x2="460" y2="' + ruleY + '"'
+ ' stroke="' + escX(border) + '" stroke-width="1"/>\n'
+ ' ' + taglineSVG + '\n'
+ ' ' + domainSVG + '\n'
+ '</svg>';
}
// --- Render into preview ---
function render() {
var tokens = getTokens();
var svg = buildSVG(
$titleCtrl.value,
$domainCtrl.value,
$taglineCtrl.value,
$blognameCtrl.value,
selectedEmoji,
tokens
);
$previewWrap.innerHTML = svg;
}
[$blognameCtrl, $titleCtrl, $domainCtrl, $taglineCtrl].forEach(function (el) {
el.addEventListener('input', render);
});
$blognameCtrl.addEventListener('input', function () {
saveBlogName(this.value);
});
// --- Emoji strip ---
$emojiStrip.addEventListener('click', function (e) {
var btn = e.target.closest('.meta-emoji-btn');
if (!btn) return;
$emojiStrip.querySelectorAll('.meta-emoji-btn').forEach(function (b) {
b.classList.remove('active');
});
btn.classList.add('active');
selectedEmoji = btn.dataset.emoji;
$customEmoji.value = '';
render();
});
function firstGrapheme(val) {
if (typeof Intl !== 'undefined' && Intl.Segmenter) {
var seg = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
var iter = seg.segment(val)[Symbol.iterator]().next();
return iter.done ? '' : iter.value.segment;
}
// Fallback: splits on code points, which can break multi-codepoint
// emoji (ZWJ sequences, some variation selectors) but is a reasonable default
return Array.from(val)[0] || '';
}
$customEmoji.addEventListener('input', function () {
var val = this.value.trim();
selectedEmoji = firstGrapheme(val);
$emojiStrip.querySelectorAll('.meta-emoji-btn').forEach(function (b) {
b.classList.remove('active');
});
render();
});
// --- PNG download via SVG → Canvas ---
$dlBtn.addEventListener('click', function () {
if ($dlBtn.disabled) return;
var tokens = getTokens();
var title = $titleCtrl.value || 'meta-image';
var svgStr = buildSVG(
$titleCtrl.value,
$domainCtrl.value,
$taglineCtrl.value,
$blognameCtrl.value,
selectedEmoji,
tokens
);
var blob = new Blob([svgStr], { type: 'image/svg+xml' });
var url = URL.createObjectURL(blob);
var img = new Image();
var originalLabel = $dlBtn.textContent;
$dlBtn.disabled = true;
$dlBtn.textContent = 'Preparing…';
function resetButton() {
$dlBtn.disabled = false;
$dlBtn.textContent = originalLabel;
}
img.onload = function () {
var canvas = document.createElement('canvas');
canvas.width = 1200;
canvas.height = 630;
canvas.getContext('2d').drawImage(img, 0, 0, 1200, 630);
URL.revokeObjectURL(url);
canvas.toBlob(function (pngBlob) {
if (!pngBlob) {
resetButton();
alert('Could not generate the PNG. Please try again.');
return;
}
var a = document.createElement('a');
var pngUrl = URL.createObjectURL(pngBlob);
a.href = pngUrl;
a.download = slugify(title) + '-meta.png';
a.click();
setTimeout(function () { URL.revokeObjectURL(pngUrl); }, 100);
resetButton();
closeModal();
}, 'image/png');
};
img.onerror = function () {
URL.revokeObjectURL(url);
resetButton();
alert('Could not render the image. Please try again.');
};
img.src = url;
});
function slugify(s) {
var base = String(s).normalize('NFD').replace(/[\u0300-\u036f]/g, '')
.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
return base || 'meta-image';
}
})();
</script>
Want more? Check out the full Bear Blog library.
Requires JavaScript, available with Bear Blog subscription.↩