blog-figure-svg

"Stop using stock photos. Generate accessible, lightweight SVG figures for any blog or CMS - flow diagrams, comparison bar charts, taxonomy/Venn diagrams, annotated terminal mocks, and 1600x840 OG feature cards. Hand-authored SVG (no embedded fonts, no external assets, no AI-image latency) with a consistent palette, screen-reader metadata (title + desc + aria-labelledby), and a figcaption-required handoff to the writer. Rasterizes to compressed PNG ready for Ghost, WordPress, Webflow, or any static-site generator. Built for content marketers, indie hackers, and dev-tool blogs that want unique illustrations on every post without paying a designer or burning Midjourney credits. Trigger when the user says: 'add a figure to the post', 'illustrate this comparison', 'draw a flow diagram for X', 'make a feature/OG image', or any request to produce a chart/diagram for editorial use."

5 Tools
publishing-skills Plugin
productivity Category

Allowed Tools

WebSearchWebFetchBash(python3:*)ReadWrite

Provided by Plugin

publishing-skills

Four composable skills that turn an AI agent into a platform-agnostic long-tail SEO publishing pipeline — topic research, drafting, SVG figures, and an editorial calendar. Ships Ghost, WordPress, and static-site adapters.

productivity v0.1.0
View Plugin

Installation

This skill is included in the publishing-skills plugin:

/plugin install publishing-skills@claude-code-plugins-plus

Click to copy

Instructions

blog-figure-svg

Produces SVG figures intended for blog posts: in-line illustrations (1 per ~500 body words is the rule of thumb) and a templated OG feature card. Output is a clean SVG file (the editable source) rasterized to a compressed PNG (what the post references). Every figure carries title + desc + role="img" so screen readers can read it.

This skill is platform-agnostic — the SVG and PNG it produces work in any CMS (Ghost, WordPress, Webflow, Sanity) or static-site generator (Hugo, Astro, Eleventy, Jekyll, Next-MDX). It complements seo-blog-writer (handles the publish step for whatever platform you're on) and blog-topic-research (validates the topic). Use it during the illustration step of writing a post — after the prose is stable so the anchor sentences are final.


/blog-figure-svg flow      "<title>"   --steps "Trigger -> Filter -> HTTP -> Slack"
/blog-figure-svg compare   "<title>"   --bars "Zapier:0.03,Make:0.015,n8n:0.008" --unit "$ per task"
/blog-figure-svg taxonomy  "<title>"   --groups "Workflows,Agents,RPA" --notes "see references/style-examples.md"
/blog-figure-svg terminal  "<title>"   --lines "$ npm install\nadded 42 packages"
/blog-figure-svg feature   "<headline>"   --accent "#4F46E5" --pill "How To"

All variants write to tmp/blog-drafts/--.svg (editable source, gitignored), then rasterize to --.png (uploaded to the blog CDN).


Before you start

The skill expects a working directory it can write into. Default: tmp/blog-drafts/. The PNG rasterizer requires one of:

  • ImageMagick (magick command) — preferred. magick -density 192 -background white in.svg -resize 1600x out.png.
  • rsvg-convertrsvg-convert -w 1600 -b white in.svg -o out.png.
  • inkscape (CLI) — inkscape --export-type=png --export-width=1600 in.svg.
  • cairosvg (Python) — pip install cairosvg; cairosvg in.svg -W 1600 -o out.png.

Plus pngquant (or oxipng) for compression — typical 60-80% size reduction with no visible quality loss. Core Web Vitals and ad-network reviews (Mediavine, Raptive) care about image weight.


command -v magick || command -v rsvg-convert || command -v inkscape || python3 -c "import cairosvg" 2>/dev/null \
  || echo "no SVG rasterizer found - install one of magick, rsvg-convert, inkscape, cairosvg"
command -v pngquant || command -v oxipng || echo "no PNG compressor - install pngquant or oxipng"

The three illustration shapes

Match each figure to a paragraph the reader has just finished, and to one concrete information structure:

Shape Use when the post... Variant
Comparison ...cites two or more numbers (prices, latencies, accuracy, counts) compare (bar chart)
Taxonomy ...introduces named categories (e.g. workflow / agent / RPA, or trigger / action / filter) taxonomy (Venn, hierarchy, or labelled groups)
Process / flow ...describes a "how to" sequence, integration topology, or decision tree flow (horizontal flow with named steps)
CLI / API mock ...shows command output, an error message, or a config blob terminal (annotated terminal mock)
Title card ...needs an OG feature image feature (1600x840 templated card)

Never plot data the post doesn't already cite. If you can't identify even one information structure to illustrate, skip — note in the report "no figures: post is too short / too definitional."

Hard rule for editorial pipelines: any post >=800 words needs at least 1 figure; figure count = max(1, body_words // 500). Sub-800-word definitional explainers are the only legitimate zero-figure case.


Palette and typography

Pick from these hex values. No new hues — consistency across figures is the brand:

Hex Role
#3b82f6 accent blue — primary data series
#fb923c orange — secondary series
#10b981 green — tertiary / positive
#0b0b11 text — titles, primary callouts
#475569 / #6b7280 / #9ca3af greys — secondary labels, axis ticks
#cbd5e1 / #94a3b8 light greys — gridlines, weak series
#fafafa background fill

Typography: font-family="ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" only. No embedded web fonts — they fail to load in feed readers, dark-mode previews, and AMP renders. Sizes: title 20px bold, section labels 14-16px, axis labels 11-13px.

ViewBox: viewBox="0 0 800 " for inline figures (a sane width for most CMS content columns, including Ghost's Casper, the WordPress block editor, and Hugo / Astro defaults); viewBox="0 0 1600 840" for OG cards. Do not set root width/height attributes — let the host theme scale.


SVG skeleton (every figure)


<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 360"
     font-family="ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif"
     role="img" aria-labelledby="t1 d1">
  <title id="t1">Short, informative title - what the figure shows</title>
  <desc id="d1">Long-form description for screen readers - what the bars/circles/lines depict, including all numbers shown on screen</desc>
  <rect width="800" height="360" fill="#fafafa"/>
  <!-- bars / circles / paths / labels -->
</svg>

Accessibility checklist:

  • role="img" on the root .
  • </code> + <code><desc></code> referenced via <code>aria-labelledby</code> (NOT <code>aria-describedby</code> — the former covers both).</li> <li>Suffix IDs with the figure number (<code>t1</code>/<code>d1</code>, <code>t2</code>/<code>d2</code>, ...) so multiple figures on one page don't collide.</li> <li><code><desc></code> includes every number visible in the figure (screen readers can't OCR the chart).</li> </ul> <p><strong>Honesty:</strong> never round towards a more dramatic gap, never anchor an axis to inflate differences. If the data is "practitioner observation, not a measured study," say so in <code><desc></code> and in a small grey caption inside the figure.</p> <hr> <h2>Variant: <code>flow</code> — horizontal process flow</h2> <p>For: "how to" sequences, integration topology, decision trees.</p> <pre data-lang="python"><code> # Args: title, steps (--steps "Trigger -> Filter -> HTTP -> Slack") import sys, html, pathlib title, steps_arg, out_path = sys.argv[1], sys.argv[2], sys.argv[3] steps = [s.strip() for s in steps_arg.split('->') if s.strip()] n = len(steps) assert 2 <= n <= 7, f"flow needs 2-7 steps, got {n}" W, H = 800, 240 margin_x = 60 gap = (W - 2*margin_x) / (n - 1) if n > 1 else 0 box_w, box_h = 130, 64 cy = H // 2 + 10 nodes = [] arrows = [] for i, s in enumerate(steps): cx = margin_x + i * gap x = cx - box_w / 2 y = cy - box_h / 2 nodes.append( f'<rect x="{x:.0f}" y="{y:.0f}" width="{box_w}" height="{box_h}" ' f'rx="8" fill="#fff" stroke="#3b82f6" stroke-width="2"/>' f'<text x="{cx:.0f}" y="{cy + 5:.0f}" text-anchor="middle" ' f'font-size="14" font-weight="600" fill="#0b0b11">{html.escape(s)}</text>' ) if i < n - 1: x1 = cx + box_w / 2 x2 = margin_x + (i + 1) * gap - box_w / 2 arrows.append( f'<line x1="{x1:.0f}" y1="{cy}" x2="{x2 - 8:.0f}" y2="{cy}" ' f'stroke="#6b7280" stroke-width="2" marker-end="url(#arrow)"/>' ) desc = f"Flow diagram showing steps: {' to '.join(steps)}." svg = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {W} {H}" font-family="ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" role="img" aria-labelledby="t1 d1"> <title id="t1">{html.escape(title)}</title> <desc id="d1">{html.escape(desc)}</desc> <defs> <marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto"> <path d="M0,0 L10,5 L0,10 z" fill="#6b7280"/> </marker> </defs> <rect width="{W}" height="{H}" fill="#fafafa"/> <text x="{W//2}" y="40" text-anchor="middle" font-size="20" font-weight="700" fill="#0b0b11">{html.escape(title)}</text> {"".join(arrows)} {"".join(nodes)} </svg>''' pathlib.Path(out_path).write_text(svg, encoding='utf-8') print(f"wrote {out_path} ({n} steps)") </code></pre> <hr> <h2>Variant: <code>compare</code> — bar chart</h2> <p>For: numeric comparisons (prices, latencies, accuracy, counts). 2-7 bars.</p> <pre data-lang="python"><code> # Args: title, bars (--bars "Zapier:0.03,Make:0.015,n8n:0.008"), unit import sys, html, pathlib title, bars_arg, unit, out_path = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4] pairs = [] for chunk in bars_arg.split(','): label, val = chunk.split(':') pairs.append((label.strip(), float(val.strip()))) n = len(pairs) assert 2 <= n <= 7, f"compare needs 2-7 bars, got {n}" W, H = 800, 360 margin_x, margin_top, margin_bottom = 80, 70, 70 plot_w = W - 2 * margin_x plot_h = H - margin_top - margin_bottom max_v = max(v for _, v in pairs) bar_w = plot_w / (n * 1.5) gap = bar_w * 0.5 colors = ['#3b82f6', '#fb923c', '#10b981', '#94a3b8', '#6b7280', '#cbd5e1', '#475569'] bars = [] labels = [] for i, (label, val) in enumerate(pairs): h = (val / max_v) * plot_h if max_v else 0 x = margin_x + i * (bar_w + gap) y = margin_top + (plot_h - h) bars.append( f'<rect x="{x:.0f}" y="{y:.0f}" width="{bar_w:.0f}" height="{h:.0f}" fill="{colors[i % len(colors)]}"/>' f'<text x="{x + bar_w/2:.0f}" y="{y - 8:.0f}" text-anchor="middle" font-size="13" font-weight="600" fill="#0b0b11">{val:g}</text>' ) labels.append( f'<text x="{x + bar_w/2:.0f}" y="{H - margin_bottom + 24:.0f}" text-anchor="middle" font-size="13" fill="#475569">{html.escape(label)}</text>' ) desc = f"Bar chart comparing {unit}: " + ", ".join(f"{label} {val:g}" for label, val in pairs) + "." svg = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {W} {H}" font-family="ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" role="img" aria-labelledby="t1 d1"> <title id="t1">{html.escape(title)}</title> <desc id="d1">{html.escape(desc)}</desc> <rect width="{W}" height="{H}" fill="#fafafa"/> <text x="{W//2}" y="36" text-anchor="middle" font-size="20" font-weight="700" fill="#0b0b11">{html.escape(title)}</text> <text x="{W//2}" y="56" text-anchor="middle" font-size="12" fill="#6b7280">{html.escape(unit)}</text> <line x1="{margin_x}" y1="{H - margin_bottom}" x2="{W - margin_x}" y2="{H - margin_bottom}" stroke="#cbd5e1" stroke-width="1"/> {"".join(bars)} {"".join(labels)} </svg>''' pathlib.Path(out_path).write_text(svg, encoding='utf-8') print(f"wrote {out_path} ({n} bars)") </code></pre> <hr> <h2>Variant: <code>taxonomy</code> — labelled groups (Venn-lite)</h2> <p>For: introducing named categories. 2-4 groups.</p> <pre data-lang="python"><code> # Args: title, groups (--groups "Workflows,Agents,RPA"), notes import sys, html, pathlib, math title, groups_arg, out_path = sys.argv[1], sys.argv[2], sys.argv[3] groups = [g.strip() for g in groups_arg.split(',') if g.strip()] n = len(groups) assert 2 <= n <= 4, f"taxonomy needs 2-4 groups, got {n}" W, H = 800, 400 cx, cy = W // 2, H // 2 + 20 r = 110 colors = ['#3b82f6', '#fb923c', '#10b981', '#94a3b8'] opacity = 0.4 circles = [] labels = [] if n == 2: positions = [(cx - 60, cy), (cx + 60, cy)] elif n == 3: positions = [(cx, cy - 50), (cx - 70, cy + 40), (cx + 70, cy + 40)] else: # 4 positions = [(cx - 70, cy - 50), (cx + 70, cy - 50), (cx - 70, cy + 50), (cx + 70, cy + 50)] for i, ((x, y), label) in enumerate(zip(positions, groups)): circles.append( f'<circle cx="{x}" cy="{y}" r="{r}" fill="{colors[i]}" fill-opacity="{opacity}" stroke="{colors[i]}" stroke-width="2"/>' ) # Label outside the circle, away from center dx, dy = x - cx, y - cy mag = math.sqrt(dx*dx + dy*dy) or 1 lx = x + (dx / mag) * (r + 30) ly = y + (dy / mag) * (r + 30) labels.append( f'<text x="{lx:.0f}" y="{ly:.0f}" text-anchor="middle" font-size="15" font-weight="600" fill="#0b0b11">{html.escape(label)}</text>' ) desc = f"Taxonomy diagram showing groups: {', '.join(groups)}, with overlapping regions indicating shared concepts." svg = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {W} {H}" font-family="ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" role="img" aria-labelledby="t1 d1"> <title id="t1">{html.escape(title)}</title> <desc id="d1">{html.escape(desc)}</desc> <rect width="{W}" height="{H}" fill="#fafafa"/> <text x="{W//2}" y="40" text-anchor="middle" font-size="20" font-weight="700" fill="#0b0b11">{html.escape(title)}</text> {"".join(circles)} {"".join(labels)} </svg>''' pathlib.Path(out_path).write_text(svg, encoding='utf-8') print(f"wrote {out_path} ({n} groups)") </code></pre> <hr> <h2>Variant: <code>terminal</code> — annotated terminal mock</h2> <p>For: command output, error messages, config blobs.</p> <pre data-lang="python"><code> # Args: title, lines (newline-separated), out_path import sys, html, pathlib title, lines_arg, out_path = sys.argv[1], sys.argv[2], sys.argv[3] lines = lines_arg.split('\n') assert 1 <= len(lines) <= 16, f"terminal needs 1-16 lines, got {len(lines)}" W = 800 line_h = 22 H = 80 + line_h * len(lines) + 30 chrome_h = 36 rows = [] for i, ln in enumerate(lines): y = 80 + chrome_h + i * line_h # Highlight error lines red, prompt lines green color = '#fb923c' if 'error' in ln.lower() or 'fail' in ln.lower() else '#10b981' if ln.startswith('$') else '#cbd5e1' rows.append( f'<text x="32" y="{y}" font-family="ui-monospace, Menlo, Consolas, monospace" ' f'font-size="14" fill="{color}" xml:space="preserve">{html.escape(ln)}</text>' ) desc = "Terminal mock showing: " + " | ".join(ln for ln in lines if ln.strip())[:200] svg = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {W} {H}" font-family="ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" role="img" aria-labelledby="t1 d1"> <title id="t1">{html.escape(title)}</title> <desc id="d1">{html.escape(desc)}</desc> <rect width="{W}" height="{H}" fill="#fafafa"/> <text x="{W//2}" y="36" text-anchor="middle" font-size="18" font-weight="700" fill="#0b0b11">{html.escape(title)}</text> <rect x="20" y="60" width="{W - 40}" height="{H - 80}" rx="8" fill="#0b0b11"/> <circle cx="40" cy="78" r="6" fill="#fb923c"/> <circle cx="58" cy="78" r="6" fill="#10b981"/> <circle cx="76" cy="78" r="6" fill="#94a3b8"/> {"".join(rows)} </svg>''' pathlib.Path(out_path).write_text(svg, encoding='utf-8') print(f"wrote {out_path} ({len(lines)} lines)") </code></pre> <hr> <h2>Variant: <code>feature</code> — OG / feature card (1600x840)</h2> <p>For: the post's hero image (Ghost <code>feature<em>image</code>, WordPress <code>featured</em>media</code>, the static-site front-matter <code>feature_image</code> field, OG previews, social cards). One per post.</p> <p>The card uses a tinted gradient background, a 24px grid pattern at 7% opacity, a soft radial highlight, and either a giant accent number (when the headline contains a 1-3 digit number) or a placeholder icon slot. Brand text (your wordmark, pill label) is configurable.</p> <pre data-lang="python"><code> # Args: headline, accent (hex), pill (short tag like "How To"), brand_wordmark, out_path import sys, html, textwrap, re, pathlib headline, accent, pill, brand, out_path = sys.argv[1:6] # Auto-fit headline: 3-line cap on common tiers (longest tier may use 4). n = len(headline) if n <= 32: size, wrap, max_lines = 120, 14, 2 elif n <= 60: size, wrap, max_lines = 92, 20, 3 elif n <= 90: size, wrap, max_lines = 76, 26, 3 else: size, wrap, max_lines = 60, 32, 4 lines = textwrap.wrap(headline, wrap)[:max_lines] line_h = int(size * 1.15) total_h = line_h * (len(lines) - 1) + size y0 = 420 - total_h // 2 + size # vertical center inside 1600x840 tspans = "".join( f'<tspan x="120" dy="{0 if i==0 else line_h}">{html.escape(line)}</tspan>' for i, line in enumerate(lines) ) # Hero element: number-as-hero when the headline has a 1-3 digit number, # otherwise a clean geometric placeholder. Skips 4-digit matches (years). m = re.search(r'\b(\d{1,3})\b', headline) if m: hero = ( f'<text x="1500" y="640" text-anchor="end" font-family="ui-sans-serif, system-ui, sans-serif" ' f'font-weight="800" font-size="500" fill="{accent}" ' f'opacity="0.20" letter-spacing="-20">{m.group(1)}</text>' ) else: # Default placeholder icon: stacked geometric shapes hero = ( f'<g transform="translate(1190,260) scale(1.0)" fill="none" stroke="{accent}" stroke-width="7" stroke-linecap="round">' f'<circle cx="140" cy="140" r="100" opacity="0.4"/>' f'<circle cx="140" cy="140" r="60" opacity="0.6"/>' f'<circle cx="140" cy="140" r="20" fill="{accent}" opacity="1"/>' f'</g>' ) svg = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 840" role="img" aria-labelledby="t1 d1"> <title id="t1">{html.escape(headline)}</title> <desc id="d1">Feature card for blog post: {html.escape(headline)}. Pill label: {html.escape(pill)}.</desc> <defs> <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1"> <stop offset="0%" stop-color="#F8FAFC"/> <stop offset="100%" stop-color="#E2E8F0"/> </linearGradient> <radialGradient id="hi" cx="0.15" cy="0.1" r="0.7"> <stop offset="0%" stop-color="{accent}" stop-opacity="0.18"/> <stop offset="100%" stop-color="{accent}" stop-opacity="0"/> </radialGradient> <pattern id="grid" x="0" y="0" width="24" height="24" patternUnits="userSpaceOnUse"> <path d="M 24 0 L 0 0 0 24" fill="none" stroke="{accent}" stroke-width="1" opacity="0.07"/> </pattern> </defs> <rect width="1600" height="840" fill="url(#bg)"/> <rect width="1600" height="840" fill="url(#grid)"/> <rect width="1600" height="840" fill="url(#hi)"/> <rect x="0" y="0" width="14" height="840" fill="{accent}"/> {hero} <text x="120" y="{y0}" font-family="ui-sans-serif, system-ui, sans-serif" font-size="{size}" font-weight="800" fill="#0F172A" letter-spacing="-2">{tspans}</text> <text x="120" y="760" font-family="ui-sans-serif, system-ui, sans-serif" font-size="28" font-weight="700" fill="{accent}" letter-spacing="2">{html.escape(brand.upper())}</text> <text x="1480" y="760" text-anchor="end" font-family="ui-sans-serif, system-ui, sans-serif" font-size="24" font-weight="600" fill="#475569" letter-spacing="1">{html.escape(pill)}</text> </svg>''' pathlib.Path(out_path).write_text(svg, encoding='utf-8') print(f"wrote {out_path} ({len(lines)} lines, hero={'number' if m else 'icon'})") </code></pre> <p><strong>Customising the hero icon:</strong> replace the placeholder <code><g></code> block with cluster-specific iconography from your project. Keep stroke width 5-9, viewBox-relative coordinates (drawn for a 280x280 box), and stroke-only fills so the icon reads at thumbnail size in social previews. Examples (n8n nodes, code brackets, agent graph, RPA grid) are easy to author — see the <code>feature</code> script's structure.</p> <hr> <h2>Rasterize SVG to PNG</h2> <p>The SVG is the editable source. The blog references PNG only — most CMSes deliver PNG more reliably through their CDN than SVG.</p> <pre data-lang="bash"><code> # Preferred: ImageMagick at 192 DPI (renders text at 2x for sharpness) for svg in tmp/blog-drafts/<slug>-*.svg; do png="${svg%.svg}.png" magick -density 192 -background white "$svg" -resize 1600x "$png" done # Or one of the fallbacks: rsvg-convert -w 1600 -b white in.svg -o out.png inkscape --export-type=png --export-width=1600 in.svg python3 -c "import cairosvg; cairosvg.svg2png(url='in.svg', write_to='out.png', output_width=1600)" </code></pre> <p><code>-density 192</code> renders text at 2x before resize (sharpness). <code>-background white</code> prevents black halos around antialiased edges. <code>-resize 1600x</code> is the practical ceiling for a CMS content column.</p> <h3>Compress before upload</h3> <p>ImageMagick output is 200-400 KB per figure; <code>pngquant</code> typically cuts that 60-80% with no visible quality loss.</p> <pre data-lang="bash"><code> for png in tmp/blog-drafts/<slug>-*.png; do pngquant --skip-if-larger --strip --output "$png" --force 256 "$png" || true done ls -lh tmp/blog-drafts/<slug>-*.png </code></pre> <p>If <code>pngquant</code> isn't installed, <code>oxipng -o 4 tmp/blog-drafts/<slug>-*.png</code> is a slower fallback. If neither is available, surface to the user and proceed — don't block the post on compression.</p> <h3>Verify the PNG</h3> <pre data-lang="bash"><code> # Confirm dimensions and bit depth magick identify tmp/blog-drafts/<slug>-*.png 2>/dev/null \ || python3 -c "from PIL import Image; import sys; [print(p, Image.open(p).size) for p in sys.argv[1:]]" tmp/blog-drafts/<slug>-*.png </code></pre> <p>Open each PNG locally and confirm: text is sharp at 100% zoom, no missing glyphs, no black halos.</p> <hr> <h2>Embed in the post</h2> <p>For each figure, identify the <strong>anchor sentence</strong> in the draft — the closing <code></p></code> of the paragraph the figure should appear after. Pick a phrase distinctive enough that <code>str.replace</code> finds exactly one match.</p> <p>Insert with a generic <code><figure></code> block (renders cleanly in every major CMS theme and every static-site generator's default Markdown→HTML pipeline):</p> <pre data-lang="html"><code> <figure> <img src="<uploaded-png-url-or-relative-path>" alt="<full description with all numbers and labels>" loading="lazy"> <figcaption>One sentence restating the takeaway in plain English (15-30 words).</figcaption> </figure> </code></pre> <p><strong>Caption rules:</strong></p> <ul> <li><strong>Required on every figure.</strong> No bare <code><img></code> and no <code><figure></code> without a <code><figcaption></code>. The <code>seo-blog-writer</code> skill's bundle validation refuses figures without captions.</li> <li>One sentence, 15-30 words, restating the takeaway in plain English (not "Figure showing X" — say what the reader should conclude).</li> <li>Allowed tags inside <code><figcaption></code>: <code><a></code> (with <code>rel="nofollow noopener"</code> for external), <code><em></code>. Nothing else.</li> <li>No "Figure 1." numbering.</li> </ul> <p><strong>Alt text rules:</strong></p> <ul> <li>Restate every label and number visible in the figure. Screen readers read alt, not the figure.</li> <li>50-200 chars. Longer than the caption.</li> </ul> <p>Verify each PNG URL appears exactly once in the draft:</p> <pre data-lang="bash"><code> python3 -c " import pathlib, re, sys html = pathlib.Path(sys.argv[1]).read_text(encoding='utf-8') for m in re.finditer(r'src=\"([^\"]+\.png)\"', html): print(m.group(1)) " tmp/blog-drafts/<slug>.draft.html | sort | uniq -c </code></pre> <p>Each URL should print <code>1</code>. Zero = anchor missed; >1 = anchor matched multiple paragraphs (extend the anchor).</p> <hr> <h2>Upload to your CMS</h2> <p>This skill doesn't ship a CMS uploader — the <code>seo-blog-writer</code> skill handles auth and the upload endpoint for each platform it targets. After generating PNGs:</p> <ul> <li><strong>For Ghost:</strong> <code>seo-blog-writer</code>'s Ghost adapter exposes an image-upload snippet (POST to <code>/ghost/api/admin/images/upload/</code> with the Admin API JWT).</li> <li><strong>For WordPress:</strong> <code>seo-blog-writer</code>'s WordPress adapter posts to <code>/wp-json/wp/v2/media</code> with application-password auth.</li> <li><strong>For static-site generators (Hugo, Astro, Eleventy, Jekyll, Next-MDX):</strong> drop the PNGs into the project's static / public / assets directory and reference relative paths in the figure tag.</li> <li><strong>For other CMSes (Webflow, Sanity, Strapi, Contentful):</strong> write a 20-line adapter that POSTs the PNG to the platform's media endpoint, then splice the returned URL.</li> </ul> <hr> <h2>Failure modes</h2> <table><thead><tr> <th>Symptom</th> <th>Cause</th> <th>Fix</th> </tr></thead><tbody> <tr> <td><code>magick: no decode delegate</code> on <code>.svg</code></td> <td>ImageMagick built without rsvg</td> <td>Fallback: <code>rsvg-convert</code>, <code>inkscape</code>, or <code>cairosvg</code></td> </tr> <tr> <td>Text rendered as boxes / missing glyphs in PNG</td> <td>Embedded font referenced but not installed</td> <td>Use only generic <code>ui-sans-serif, system-ui</code> font families; no <code>@font-face</code></td> </tr> <tr> <td>Black halos around shapes in PNG</td> <td>Antialiased SVG rendered against a transparent background</td> <td>Pass <code>-background white</code> to ImageMagick</td> </tr> <tr> <td>PNG looks blurry</td> <td>Rasterized at 96 DPI</td> <td>Use <code>-density 192</code> (or <code>-w 1600</code> with rsvg/cairosvg)</td> </tr> <tr> <td><code>aria-labelledby</code> ignored by screen readers</td> <td>Missing <code>role="img"</code> on the root <code><svg></code></td> <td>Add <code>role="img"</code> — without it, the SVG is treated as a graphic group</td> </tr> <tr> <td>Feature card text overflows the 1600x840 canvas</td> <td>Headline longer than ~120 chars</td> <td>Truncate headline or use the longest tier (60pt, 4 lines, 32 chars/line)</td> </tr> <tr> <td>Figcaption missing on a <code><figure></code></td> <td>Manually pasted <code><img></code> not wrapped in <code><figure></code></td> <td>Wrap in <code><figure>...<figcaption>...</figcaption></figure></code> — every figure needs a caption</td> </tr> </tbody></table> <hr> <h2>Companion skills</h2> <ul> <li><strong><code>blog-topic-research</code></strong> — validates that a long-tail topic has real demand signals before drafting.</li> <li><strong><code>seo-blog-writer</code></strong> — drafts, scrubs, AI-SEO-audits, and publishes the post to your CMS (Ghost, WordPress, or static-site) via the platform adapter.</li> </ul> <p>Together, the three form a complete long-tail SEO publishing pipeline: research the topic, write the post, illustrate it, publish.</p></div> </div> </section> <!-- 8. Bottom CTA --> <section class="detail-cta" data-astro-cid-qcigfm7x> <h2 class="detail-cta__headline" data-astro-cid-qcigfm7x>Ready to use publishing-skills?</h2> <div class="detail-cta__actions" data-astro-cid-qcigfm7x> <button class="detail-cta__primary" data-copy-install="/plugin install publishing-skills@claude-code-plugins-plus" data-astro-cid-qcigfm7x> <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true" data-astro-cid-qcigfm7x> <rect x="5" y="5" width="9" height="9" rx="1.5" stroke="currentColor" stroke-width="1.5" data-astro-cid-qcigfm7x></rect> <path d="M3 11V3a1 1 0 011-1h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" data-astro-cid-qcigfm7x></path> </svg> <span class="detail-cta__btn-text" data-astro-cid-qcigfm7x>Copy Install Command</span> </button> </div> </section> <script type="module">document.querySelectorAll("[data-copy-install]").forEach(t=>{t.addEventListener("click",async()=>{const a=t.dataset.copyInstall||"";await navigator.clipboard.writeText(a);const e=t.querySelector(".detail-cta__btn-text");if(e){const o=e.textContent;e.textContent="Copied!",t.classList.add("copied"),window.trackEvent&&window.trackEvent("install_copied",{plugin_name:a.split(" ").pop()||"",source:"detail_cta"}),setTimeout(()=>{e.textContent=o,t.classList.remove("copied")},2e3)}})});</script> <!-- 9. Related Skills (6, with tool pills) --> <section class="related-skills-section" data-astro-cid-jrlgpo3w> <h2 class="related-skills-heading" data-astro-cid-jrlgpo3w>Related Skills</h2> <div class="related-skills-grid" data-astro-cid-jrlgpo3w> <a href="/skills/000-jeremy-content-consistency-validator/" class="related-skill-card" data-astro-cid-jrlgpo3w> <h3 class="related-skill-title" data-astro-cid-jrlgpo3w>000-jeremy-content-consistency-validator</h3> <p class="related-skill-description" data-astro-cid-jrlgpo3w>'Validate messaging consistency across website, GitHub repos, and local.</p> <div class="related-skill-tools" data-astro-cid-jrlgpo3w> <span class="related-tool-pill" data-astro-cid-jrlgpo3w>Read</span><span class="related-tool-pill" data-astro-cid-jrlgpo3w>WebFetch</span><span class="related-tool-pill" data-astro-cid-jrlgpo3w>WebSearch</span> </div> </a><a href="/skills/agent-context-loader/" class="related-skill-card" data-astro-cid-jrlgpo3w> <h3 class="related-skill-title" data-astro-cid-jrlgpo3w>agent-context-loader</h3> <p class="related-skill-description" data-astro-cid-jrlgpo3w>'Execute proactive auto-loading: automatically detects and loads agents.</p> <div class="related-skill-tools" data-astro-cid-jrlgpo3w> <span class="related-tool-pill" data-astro-cid-jrlgpo3w>Read</span><span class="related-tool-pill" data-astro-cid-jrlgpo3w>Write</span><span class="related-tool-pill" data-astro-cid-jrlgpo3w>Edit</span> </div> </a><a href="/skills/api-testing/" class="related-skill-card" data-astro-cid-jrlgpo3w> <h3 class="related-skill-title" data-astro-cid-jrlgpo3w>api-testing</h3> <p class="related-skill-description" data-astro-cid-jrlgpo3w>"Use when testing HTTP endpoints, probing URLs for status and headers, or running declarative API test suites from plain text files".</p> <div class="related-skill-tools" data-astro-cid-jrlgpo3w> <span class="related-tool-pill" data-astro-cid-jrlgpo3w>[Bash(hurl*)</span><span class="related-tool-pill" data-astro-cid-jrlgpo3w>Bash(httpx*)</span><span class="related-tool-pill" data-astro-cid-jrlgpo3w>Read</span> </div> </a><a href="/skills/audit-plugin/" class="related-skill-card" data-astro-cid-jrlgpo3w> <h3 class="related-skill-title" data-astro-cid-jrlgpo3w>audit-plugin</h3> <p class="related-skill-description" data-astro-cid-jrlgpo3w>Performs a deep review of the Claude Code plugin, skill, or sub-agent defined in the current project against official best practices.</p> <div class="related-skill-tools" data-astro-cid-jrlgpo3w> <span class="related-tool-pill" data-astro-cid-jrlgpo3w>Read Glob Grep Bash WebSearch WebFetch</span> </div> </a><a href="/skills/audit-standards/" class="related-skill-card" data-astro-cid-jrlgpo3w> <h3 class="related-skill-title" data-astro-cid-jrlgpo3w>audit-standards</h3> <p class="related-skill-description" data-astro-cid-jrlgpo3w>Audits the current project against the development standards defined in ~/.</p> <div class="related-skill-tools" data-astro-cid-jrlgpo3w> <span class="related-tool-pill" data-astro-cid-jrlgpo3w>Read Glob Grep Bash</span> </div> </a><a href="/skills/blog-editorial-calendar/" class="related-skill-card" data-astro-cid-jrlgpo3w> <h3 class="related-skill-title" data-astro-cid-jrlgpo3w>blog-editorial-calendar</h3> <p class="related-skill-description" data-astro-cid-jrlgpo3w>"Run your blog like a queue, not a guessing game.</p> <div class="related-skill-tools" data-astro-cid-jrlgpo3w> <span class="related-tool-pill" data-astro-cid-jrlgpo3w>WebSearch</span><span class="related-tool-pill" data-astro-cid-jrlgpo3w>WebFetch</span><span class="related-tool-pill" data-astro-cid-jrlgpo3w>Read</span> </div> </a> </div> </section> <!-- 10. Footer --> <footer class="skill-footer" data-astro-cid-jrlgpo3w> <p data-astro-cid-jrlgpo3w>Part of <a href="/plugins/publishing-skills/" data-astro-cid-jrlgpo3w>publishing-skills</a></p> </footer> </div> <script> document.querySelectorAll('.install-command').forEach(el => { el.addEventListener('click', function() { const text = this.dataset.copy || this.textContent; navigator.clipboard.writeText(text).then(() => { const originalText = this.textContent; this.textContent = 'Copied to clipboard!'; this.style.color = 'var(--brand-green)'; setTimeout(() => { this.textContent = originalText; this.style.color = ''; }, 2000); }); }); }); </script> </main> <!-- Footer --> <footer data-astro-cid-37fxchfa> <div class="footer-inner" data-astro-cid-37fxchfa> <div class="footer-signup-row" data-astro-cid-37fxchfa> <h4 data-astro-cid-37fxchfa>Agent Skills in Your Inbox</h4> <form data-signup-form="footer" class="footer-signup-form" data-astro-cid-37fxchfa> <input type="email" name="email" placeholder="you@example.com" required data-astro-cid-37fxchfa> <input type="text" name="website" tabindex="-1" autocomplete="off" aria-hidden="true" style="position:absolute;left:-9999px;opacity:0;height:0;width:0;" data-astro-cid-37fxchfa> <button type="submit" data-astro-cid-37fxchfa>Subscribe</button> </form> <p class="footer-form-note" data-astro-cid-37fxchfa>No spam, ever. Unsubscribe with one click.</p> </div> <div class="footer-columns" data-astro-cid-37fxchfa> <div class="footer-col" data-astro-cid-37fxchfa> <h5 data-astro-cid-37fxchfa>Product</h5> <ul data-astro-cid-37fxchfa> <li data-astro-cid-37fxchfa><a href="/explore" data-astro-cid-37fxchfa>Explore</a></li> <li data-astro-cid-37fxchfa><a href="/skills" data-astro-cid-37fxchfa>Skills</a></li> <li data-astro-cid-37fxchfa><a href="/cowork" data-astro-cid-37fxchfa>Cowork</a></li> <li data-astro-cid-37fxchfa><a href="/compare-marketplaces" data-astro-cid-37fxchfa>Compare</a></li> <li data-astro-cid-37fxchfa><a href="/tools" data-astro-cid-37fxchfa>Tools</a></li> </ul> </div> <div class="footer-col" data-astro-cid-37fxchfa> <h5 data-astro-cid-37fxchfa>Resources</h5> <ul data-astro-cid-37fxchfa> <li data-astro-cid-37fxchfa><a href="/docs" data-astro-cid-37fxchfa>Docs</a></li> <li data-astro-cid-37fxchfa><a href="/changelog" data-astro-cid-37fxchfa>Changelog</a></li> <li data-astro-cid-37fxchfa><a href="/collections" data-astro-cid-37fxchfa>Collections</a></li> <li data-astro-cid-37fxchfa><a href="/playbooks" data-astro-cid-37fxchfa>Playbooks</a></li> <li data-astro-cid-37fxchfa><a href="/research" data-astro-cid-37fxchfa>Research</a></li> <li data-astro-cid-37fxchfa><a href="/learning" data-astro-cid-37fxchfa>Learning</a></li> </ul> </div> <div class="footer-col" data-astro-cid-37fxchfa> <h5 data-astro-cid-37fxchfa>Company</h5> <ul data-astro-cid-37fxchfa> <li data-astro-cid-37fxchfa><a href="/community" data-astro-cid-37fxchfa>Community</a></li> <li data-astro-cid-37fxchfa><a href="/community#hall-of-fame" data-astro-cid-37fxchfa>Hall of Fame</a></li> <li data-astro-cid-37fxchfa><a href="https://github.com/jeremylongshore/claude-code-plugins" target="_blank" data-astro-cid-37fxchfa>GitHub</a></li> </ul> </div> <div class="footer-col" data-astro-cid-37fxchfa> <h5 data-astro-cid-37fxchfa>Legal</h5> <ul data-astro-cid-37fxchfa> <li data-astro-cid-37fxchfa><a href="/privacy" data-astro-cid-37fxchfa>Privacy</a></li> <li data-astro-cid-37fxchfa><a href="/terms" data-astro-cid-37fxchfa>Terms</a></li> <li data-astro-cid-37fxchfa><a href="/acceptable-use" data-astro-cid-37fxchfa>Acceptable Use</a></li> </ul> </div> </div> <div class="footer-bottom" data-astro-cid-37fxchfa> <p class="footer-blurb" data-astro-cid-37fxchfa>Tons of Skills by <a href="https://intentsolutions.io" target="_blank" data-astro-cid-37fxchfa>Intent Solutions</a>. Marine. Citadel Grad. 20 years ops → self-taught dev → AI architect.</p> <p class="footer-copyright" data-astro-cid-37fxchfa>© 2026 Tons of Skills | <a href="https://intentsolutions.io" target="_blank" data-astro-cid-37fxchfa>Intent Solutions</a></p> </div> </div> </footer> <!-- Light theme global overrides (must be unscoped to target body/nav/footer) --> <!-- Email signup + killer-skill nomination forms. Posts to /api/forms/{signup,nominate} on the same origin. The endpoint is a tiny Node service on the VPS (`forms-api.service`, 127.0.0.1:8090) that forwards to the Slack webhook in /etc/intentsolutions/notify.env. Anti-spam: honeypot field `website`, email/URL regex, 8 KiB body cap, per-IP rate limit on the server. See intentsolutions-vps-runbook/docs/tonsofskills-forms-api.md. --> <script type="module"> (() => { const flash = (btn, msg, ok = true, restoreMs = 3000) => { const orig = btn.dataset.origText ?? btn.textContent; if (!btn.dataset.origText) btn.dataset.origText = orig; btn.textContent = msg; btn.style.opacity = ok ? '0.85' : '1'; btn.disabled = ok; if (!ok) setTimeout(() => { btn.textContent = orig; btn.style.opacity = ''; btn.disabled = false; }, restoreMs); }; const wireSignup = (form) => form.addEventListener('submit', async (e) => { e.preventDefault(); const btn = form.querySelector('button[type="submit"]'); const emailInput = form.querySelector('input[type="email"]'); const honey = form.querySelector('input[name="website"]'); const email = (emailInput?.value || '').trim(); if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { flash(btn, 'Invalid email', false); return; } flash(btn, 'Subscribing…', true, 0); try { const r = await fetch('/api/forms/signup', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email, source: form.dataset.signupForm || 'unknown', website: honey?.value || '' }) }); if (r.ok) { flash(btn, "You're in!", true, 0); if (emailInput) emailInput.value = ''; } else if (r.status === 429) { flash(btn, 'Slow down', false); } else { const j = await r.json().catch(() => ({})); flash(btn, j.error || 'Something went wrong', false); } } catch { flash(btn, 'Network error', false); } }); const wireNomination = (form) => form.addEventListener('submit', async (e) => { e.preventDefault(); const btn = form.querySelector('button[type="submit"]'); const repoInput = form.querySelector('input[name="repo"]'); const honey = form.querySelector('input[name="website"]'); const repoUrl = (repoInput?.value || '').trim(); if (!/^https:\/\/github\.com\/[^/\s]+\/[^/\s]+/.test(repoUrl)) { flash(btn, 'GitHub URL only', false); return; } flash(btn, 'Submitting…', true, 0); try { const r = await fetch('/api/forms/nominate', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ repoUrl, source: form.dataset.nominationForm || 'unknown', website: honey?.value || '' }) }); if (r.ok) { flash(btn, 'Submitted!', true, 0); if (repoInput) repoInput.value = ''; } else if (r.status === 429) { flash(btn, 'Slow down', false); } else { const j = await r.json().catch(() => ({})); flash(btn, j.error || 'Something went wrong', false); } } catch { flash(btn, 'Network error', false); } }); const wireContact = (form) => form.addEventListener('submit', async (e) => { e.preventDefault(); const btn = form.querySelector('button[type="submit"]'); const nameInput = form.querySelector('input[name="name"]'); const emailInput = form.querySelector('input[type="email"]'); const messageInput = form.querySelector('textarea[name="message"]'); const honey = form.querySelector('input[name="website"]'); const name = (nameInput?.value || '').trim(); const email = (emailInput?.value || '').trim(); const message = (messageInput?.value || '').trim(); if (name.length < 2) { flash(btn, 'Name required', false); return; } if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { flash(btn, 'Invalid email', false); return; } if (message.length < 10) { flash(btn, 'Tell me more', false); return; } flash(btn, 'Sending…', true, 0); try { const r = await fetch('/api/forms/contact', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name, email, message, source: form.dataset.contactForm || 'unknown', website: honey?.value || '' }) }); if (r.ok) { flash(btn, "Got it — back within 24h!", true, 0); if (nameInput) nameInput.value = ''; if (emailInput) emailInput.value = ''; if (messageInput) messageInput.value = ''; } else if (r.status === 429) { flash(btn, 'Slow down', false); } else { const j = await r.json().catch(() => ({})); flash(btn, j.error || 'Something went wrong', false); } } catch { flash(btn, 'Network error', false); } }); const init = () => { document.querySelectorAll('[data-signup-form]').forEach(wireSignup); document.querySelectorAll('[data-nomination-form]').forEach(wireNomination); document.querySelectorAll('[data-contact-form]').forEach(wireContact); }; if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })(); </script> <!-- Theme Toggle Script --> <script> document.addEventListener('DOMContentLoaded', function() { var themeToggle = document.querySelector('.theme-toggle'); if (themeToggle) { themeToggle.addEventListener('click', function() { var current = document.documentElement.getAttribute('data-theme') || 'dark'; var next = current === 'dark' ? 'light' : 'dark'; document.documentElement.setAttribute('data-theme', next); localStorage.setItem('theme', next); var meta = document.querySelector('meta[name="theme-color"]'); if (meta) meta.setAttribute('content', next === 'dark' ? '#0a0a0c' : '#fafaf9'); }); } window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', function(e) { if (!localStorage.getItem('theme')) { document.documentElement.setAttribute('data-theme', e.matches ? 'light' : 'dark'); } }); }); </script> <!-- Nav Scroll State --> <script> (function() { var nav = document.querySelector('nav'); if (!nav) return; function onScroll() { if (window.scrollY > 20) { nav.classList.add('scrolled'); } else { nav.classList.remove('scrolled'); } } window.addEventListener('scroll', onScroll, { passive: true }); onScroll(); })(); </script> <!-- Mobile Menu Script --> <script> document.addEventListener('DOMContentLoaded', function() { var menuToggle = document.querySelector('.mobile-menu-toggle'); var navLinks = document.querySelector('.nav-links'); var menuIcon = document.querySelector('.menu-icon'); var closeIcon = document.querySelector('.close-icon'); if (menuToggle && navLinks) { menuToggle.addEventListener('click', function() { navLinks.classList.toggle('active'); document.body.classList.toggle('menu-open'); if (navLinks.classList.contains('active')) { menuIcon.style.display = 'none'; closeIcon.style.display = 'block'; } else { menuIcon.style.display = 'block'; closeIcon.style.display = 'none'; } }); navLinks.querySelectorAll('a').forEach(function(link) { link.addEventListener('click', function() { navLinks.classList.remove('active'); document.body.classList.remove('menu-open'); menuIcon.style.display = 'block'; closeIcon.style.display = 'none'; }); }); document.addEventListener('click', function(e) { if (navLinks.classList.contains('active') && !navLinks.contains(e.target) && !menuToggle.contains(e.target)) { navLinks.classList.remove('active'); document.body.classList.remove('menu-open'); menuIcon.style.display = 'block'; closeIcon.style.display = 'none'; } }); } // Track outbound link clicks (nav + footer) document.querySelectorAll('nav a[target="_blank"], footer a[target="_blank"]').forEach(function(link) { link.addEventListener('click', function() { if (window.trackEvent) { window.trackEvent('outbound_click', { url: link.href, link_text: link.textContent.trim() }); } }); }); }); </script> </body> </html>