Social image templates
@brandkit/social-image-templates
Generate on-brand social image templates as editable SVGs from your brand.md tokens: an Open Graph image (1200x630), a square post (1080x1080), and a story (1080x1920 with safe zones). Checks text contrast and fits the headline for you.
- Activity
- New: be the first to try it
- Installs
- 0 installs
- Likes
- 0 likes
- Clones
- 0 clones
- 1#!/usr/bin/env node
- 2// Builds on-brand SVG social image templates from the tokens block in brand.md.
- 3// Usage: node scripts/make-templates.mjs [brand.md] [out-dir]
- 4// [--title "Headline"] [--subtitle "Supporting line"] [--footer "yourdomain.com"]
- 5// [--style brand|light|dark] [--logo path/to/logo.svg] [--link-logo]
- 6// Writes: og-1200x630.svg, square-1080.svg, story-1080x1920.svg, templates.json
- 7import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
- 8import { dirname, extname, join, relative, resolve } from "node:path";
- 9import { readTokens } from "./tokens-lib.mjs";
- 11const args = process.argv.slice(2);
- 12function flag(name, fallback) {
- 13 const i = args.indexOf(name);
- 14 if (i === -1) return fallback;
- 15 const value = args[i + 1] ?? "";
- 16 args.splice(i, 2);
- 17 return value;
- 18}
- 19function bool(name) {
- 20 const i = args.indexOf(name);
- 21 if (i === -1) return false;
- 22 args.splice(i, 1);
- 23 return true;
- 24}
- 25const title = flag("--title", "Your headline goes here, in a few short words");
- 26const subtitle = flag("--subtitle", "A supporting line that says what the reader gets");
- 27const style = flag("--style", "brand");
- 28const logoFlag = flag("--logo", "");
- 29const linkLogo = bool("--link-logo");
- 30let footer = flag("--footer", "");
- 31const [input = "brand.md", outDir = "social-templates"] = args;
- 33function fail(msg) {
- 34 console.error(`make-templates: ${msg}`);
- 35 process.exit(1);
- 36}
- 37if (!["brand", "light", "dark"].includes(style)) fail(`--style must be brand, light, or dark (got "${style}")`);
- 38if (!existsSync(input)) fail(`${input} not found. Pass the path to your brand guide, for example docs/brand.md.`);
- 40let tokens;
- 41try {
- 42 tokens = readTokens(readFileSync(input, "utf8"));
- 43} catch (err) {
- 44 fail(`${input}: ${err.message}`);
- 45}
- 46const color = tokens.color ?? {};
- 47const font = tokens.font ?? {};
- 48const logo = tokens.logo ?? {};
- 49const name = tokens.name ? String(tokens.name) : "";
- 50if (!footer) footer = name;
- 52// ---- color helpers -------------------------------------------------------
- 53function hex(value) {
- 54 const v = String(value ?? "").trim();
- 55 const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(v);
- 56 if (!m) return null;
- 57 const h = m[1].length === 3 ? [...m[1]].map((c) => c + c).join("") : m[1];
- 58 return `#${h.toUpperCase()}`;
- 59}
- 60function luminance(h) {
- 61 const [r, g, b] = [1, 3, 5].map((i) => {
- 62 const c = parseInt(h.slice(i, i + 2), 16) / 255;
- 63 return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
- 64 });
- 65 return 0.2126 * r + 0.7152 * g + 0.0722 * b;
- 66}
- 67function contrast(a, b) {
- 68 const [l1, l2] = [luminance(a), luminance(b)].sort((x, y) => y - x);
- 69 return Math.round(((l1 + 0.05) / (l2 + 0.05)) * 100) / 100;
- 70}
- 71function pick(...keys) {
- 72 for (const k of keys) {
- 73 const h = hex(color[k]);
- 74 if (h) return h;
- 75 }
- 76 return null;
- 77}
- 79const primary = pick("primary", "brand", "main");
- 80if (!primary) fail(`${input}: the tokens block needs color.primary as a hex color, for example "#1F7A5C".`);
- 81const ink = pick("ink", "text", "foreground", "black") ?? "#111111";
- 82const paper = pick("paper", "background", "white", "surface") ?? "#FFFFFF";
- 83const accent = pick("accent", "secondary", "primary-dark") ?? primary;
- 85const background = style === "brand" ? primary : style === "dark" ? ink : paper;
- 86// Text color: whichever of paper and ink reads best on the background.
- 87const text = contrast(paper, background) >= contrast(ink, background) ? paper : ink;
- 88const textContrast = contrast(text, background);
- 89// Accent bar: the accent if it shows up against the background, else the text color.
- 90const bar = contrast(accent, background) >= 1.5 ? accent : text;
- 92const warnings = [];
- 93if (textContrast < 4.5) {
- 94 warnings.push(
- 95 `text contrast is ${textContrast}:1 on the ${style} background, below the 4.5:1 minimum. Try --style light or dark.`,
- 96 );
- 97}
- 99function fontStack(value, fallback) {
- 100 const list = Array.isArray(value) ? value : String(value ?? "").split(",");
- 101 const clean = list.map((s) => String(s).trim()).filter(Boolean);
- 102 return (clean.length ? clean : fallback).map((f) => (/\s/.test(f) ? `'${f}'` : f)).join(", ");
- 103}
- 104const headingFont = fontStack(font.heading ?? font.display, ["Georgia", "serif"]);
- 105const bodyFont = fontStack(font.body ?? font.text, ["system-ui", "sans-serif"]);
- 107// ---- logo ----------------------------------------------------------------
- 108const logoPath = logoFlag || String(logo.primary ?? logo.mark ?? "");
- 109let logoHref = null;
- 110let logoNote = "no logo found: using the brand name as a wordmark";
- 111if (logoPath) {
- 112 const fromGuide = logoFlag ? logoPath : join(dirname(input), logoPath);
- 113 const candidates = [fromGuide, logoPath];
- 114 const found = candidates.find((p) => existsSync(p));
- 115 if (found) {
- 116 const ext = extname(found).toLowerCase();
- 117 const mime = { ".svg": "image/svg+xml", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg" }[ext];
- 118 if (!mime) {
- 119 logoNote = `logo ${found} is not svg, png, or jpg: using the wordmark`;
- 120 } else if (linkLogo) {
- 121 logoHref = relative(resolve(outDir), resolve(found));
- 122 logoNote = `logo linked from ${found}`;
- 123 } else {
- 124 logoHref = `data:${mime};base64,${readFileSync(found).toString("base64")}`;
- 125 logoNote = `logo embedded from ${found}`;
- 126 }
- 127 } else {
- 128 logoNote = `logo ${logoPath} not found: using the brand name as a wordmark`;
- 129 }
- 130}
- 131if (!logoHref) warnings.push(logoNote);
- 133// ---- layout --------------------------------------------------------------
- 134function esc(s) {
- 135 return String(s)
- 136 .replace(/&/g, "&")
- 137 .replace(/</g, "<")
- 138 .replace(/>/g, ">")
- 139 .replace(/"/g, """);
- 140}
- 141function wrap(s, maxChars) {
- 142 const words = String(s).split(/\s+/).filter(Boolean);
- 143 const lines = [];
- 144 let line = "";
- 145 for (const w of words) {
- 146 if (line && (line + " " + w).length > maxChars) {
- 147 lines.push(line);
- 148 line = w;
- 149 } else line = line ? `${line} ${w}` : w;
- 150 }
- 151 if (line) lines.push(line);
- 152 return lines;
- 153}
- 154// Shrinks the font until the text fits in maxLines. glyph is the average character width
- 155// in em: about 0.6 for bold headings, 0.52 for body text. Wide fonts may need a shorter title.
- 156function fit(s, width, size, maxLines, minSize, glyph) {
- 157 let current = size;
- 158 for (;;) {
- 159 const lines = wrap(s, Math.max(8, Math.floor(width / (current * glyph))));
- 160 if (lines.length <= maxLines || current <= minSize) return { size: current, lines };
- 161 current = Math.round(current * 0.9);
- 162 }
- 163}
- 164function textBlock(id, x, y, fitted, family, weight, fill, opacity = 1) {
- 165 const lh = Math.round(fitted.size * 1.15);
- 166 const spans = fitted.lines
- 167 .map((l, i) => `<tspan x="${x}" dy="${i === 0 ? 0 : lh}">${esc(l)}</tspan>`)
- 168 .join("");
- 169 const op = opacity < 1 ? ` fill-opacity="${opacity}"` : "";
- 170 return {
- 171 svg: `<text id="${id}" x="${x}" y="${y}" font-family="${family}" font-size="${fitted.size}" font-weight="${weight}" fill="${fill}"${op}>${spans}</text>`,
- 172 bottom: y + lh * (fitted.lines.length - 1),
- 173 };
- 174}
- 176// The SVG namespace is an identifier, not an address this script contacts.
- 177const SVG_NS = "http://www.w3.org/2000/svg";
- 179const FORMATS = [
- 180 {
- 181 file: "og-1200x630.svg",
- 182 label: "Open Graph and link previews (LinkedIn, X, Slack)",
- 183 w: 1200, h: 630, pad: 80, title: [64, 3, 40], sub: [30, 2], top: 190, logoH: 44, safe: null,
- 184 },
- 185 {
- 186 file: "square-1080.svg",
- 187 label: "Square posts (Instagram, LinkedIn)",
- 188 w: 1080, h: 1080, pad: 96, title: [84, 5, 52], sub: [38, 3], top: 300, logoH: 56, safe: null,
- 189 },
- 190 {
- 191 file: "story-1080x1920.svg",
- 192 label: "Stories and Reels (keep text out of the top 250 and bottom 340 px)",
- 193 w: 1080, h: 1920, pad: 96, title: [96, 6, 60], sub: [44, 4], top: 720, logoH: 64, safe: { top: 250, bottom: 340 },
- 194 },
- 195];
- 197const results = [];
- 198mkdirSync(outDir, { recursive: true });
- 199for (const f of FORMATS) {
- 200 const inner = f.w - f.pad * 2;
- 201 const t = fit(title, inner, f.title[0], f.title[1], f.title[2], 0.6);
- 202 if (t.lines.length > f.title[1]) warnings.push(`${f.file}: title still runs ${t.lines.length} lines; shorten it`);
- 203 const s = fit(subtitle, inner, f.sub[0], f.sub[1], Math.round(f.sub[0] * 0.8), 0.52);
- 204 const titleEl = textBlock("title", f.pad, f.top, t, headingFont, 700, text);
- 205 const subTop = titleEl.bottom + Math.round(s.size * 2.3);
- 206 const subEl = textBlock("subtitle", f.pad, subTop, s, bodyFont, 400, text, 0.88);
- 207 const bottomEdge = f.h - (f.safe ? f.safe.bottom : f.pad);
- 208 const logoY = bottomEdge - f.logoH;
- 209 const barY = f.top - Math.round(t.size * 1.6);
- 210 const logoEl = logoHref
- 211 ? `<image id="logo" x="${f.pad}" y="${logoY}" height="${f.logoH}" width="${f.logoH * 4}" preserveAspectRatio="xMinYMid meet" href="${esc(logoHref)}"/>`
- 212 : `<text id="logo" x="${f.pad}" y="${logoY + Math.round(f.logoH * 0.78)}" font-family="${headingFont}" font-size="${Math.round(f.logoH * 0.8)}" font-weight="700" fill="${text}">${esc(name || "Brand")}</text>`;
- 213 const footerEl =
- 214 footer && logoHref
- 215 ? `<text id="footer" x="${f.w - f.pad}" y="${logoY + Math.round(f.logoH * 0.7)}" text-anchor="end" font-family="${bodyFont}" font-size="${Math.round(f.logoH * 0.5)}" fill="${text}" fill-opacity="0.8">${esc(footer)}</text>`
- 216 : "";
- 217 const safeNote = f.safe
- 218 ? `\n <!-- Safe zone: platform UI covers the top ${f.safe.top}px and bottom ${f.safe.bottom}px. Keep text and logo between them. -->`
- 219 : "";
- 220 const svg = `<svg xmlns="${SVG_NS}" width="${f.w}" height="${f.h}" viewBox="0 0 ${f.w} ${f.h}">
- 221 <!-- ${esc(name || "Brand")} template: ${esc(f.label)}. Generated by social-image-templates from ${esc(input)}. Edit the text in #title and #subtitle. -->${safeNote}
- 222 <rect id="background" width="${f.w}" height="${f.h}" fill="${background}"/>
- 223 <rect id="accent-bar" x="${f.pad}" y="${barY}" width="${Math.round(f.w * 0.08)}" height="${Math.max(6, Math.round(f.h * 0.012))}" rx="3" fill="${bar}"/>
- 224 ${titleEl.svg}
- 225 ${subEl.svg}
- 226 ${logoEl}${footerEl ? `\n ${footerEl}` : ""}
- 227</svg>
- 228`;
- 229 writeFileSync(join(outDir, f.file), svg);
- 230 results.push({
- 231 file: f.file,
- 232 width: f.w,
- 233 height: f.h,
- 234 use: f.label,
- 235 title_lines: t.lines.length,
- 236 title_size: t.size,
- 237 subtitle_size: s.size,
- 238 });
- 239}
- 241const manifest = {
- 242 brand: name,
- 243 source: input,
- 244 style,
- 245 colors: { background, text, accent_bar: bar },
- 246 text_contrast: textContrast,
- 247 fonts: { heading: headingFont, body: bodyFont },
- 248 logo: logoNote,
- 249 templates: results,
- 250 warnings,
- 251};
- 252writeFileSync(join(outDir, "templates.json"), `${JSON.stringify(manifest, null, 2)}\n`);
- 254console.log(`Wrote ${results.length} templates to ${outDir}/ (${style} style, text contrast ${textContrast}:1)`);
- 255for (const r of results) console.log(` ${join(outDir, r.file)} ${r.width}x${r.height} ${r.use}`);
- 256for (const w of warnings) console.log(` note: ${w}`);
