Skip to content
SlashClone
Made by@brandkit

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.

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