Robert Birming

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

Meta image generator modal open in the Bear Blog editor, showing a live preview alongside title, blog name, tagline, domain, and emoji fields

How to use

Installation

  1. Copy the script below.
  2. Go to Customise dashboard.
  3. Paste it under "Dashboard footer content".
  4. Save. Enjoy.

Generating an image

  1. Open a post and click Meta image in the editor's link bar.
  2. The title field pre-fills from your post automatically, edit it if needed.
  3. Optionally fill in a blog name, tagline, domain, and pick an accent emoji.
  4. Watch the live preview update as you type.
  5. Click Download PNG. The file saves as your-post-title-meta.png and the window closes automatically.
  6. 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, '&amp;')
            .replace(/</g, '&lt;')
            .replace(/>/g, '&gt;')
            .replace(/"/g, '&quot;');
    }

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

  1. Requires JavaScript, available with Bear Blog subscription.