DEV Community

Cover image for Building Single-File Calculators and Comparison Tools for WordPress (No Plugin, No Framework)
M Saqlain Aslam
M Saqlain Aslam

Posted on

Building Single-File Calculators and Comparison Tools for WordPress (No Plugin, No Framework)

I run UniversalNest, a WordPress publication. Most of what we publish is articles, but some topics are better served by a small interactive tool: a calculator or a side-by-side comparison. I've built a few, and I settled on one pattern that I now reuse: one self-contained HTML file, no framework, no build step, no plugin.

Here is why I chose it and where it breaks down.

Why not a WordPress plugin?

A plugin is the "correct" answer, but for a single tool it carries a lot of overhead:

  • Plugin updates and compatibility checks for something that renders one widget
  • A PHP layer for logic that is really just client-side math
  • Extra scripts and styles loaded on every page, not just the one that needs them

A single HTML file has none of that. It can be dropped into a page through a custom HTML block or an iframe, or hosted on its own path.

The pattern: data as a plain array, render as a pure function

Every tool follows the same shape. The data lives in one array at the top of the script, and one render() function rebuilds the UI from it. This is the real script from my AI model comparison tool:

const MODELS = [
  {
    maker: "OpenAI",
    name: "GPT-5.6 Sol",
    type: "closed",
    coding: 91.9,
    codingLabel: "91.9% Terminal-Bench",
    price: 30,
    priceLabel: "$30 / M tokens",
    context: 400,
    contextLabel: "400K tokens"
  },
  // ...more models, same shape
];

const maxCoding = Math.max(...MODELS.map(m => m.coding));
const maxContext = Math.max(...MODELS.map(m => m.context));

function render(sortKey) {
  let list = [...MODELS];

  if (sortKey === "coding")       list.sort((a, b) => b.coding - a.coding);
  else if (sortKey === "cheap")   list.sort((a, b) => a.price - b.price);
  else if (sortKey === "context") list.sort((a, b) => b.context - a.context);

  grid.innerHTML = list.map((m, i) => {
    const codingPct  = Math.round((m.coding  / maxCoding)  * 100);
    const contextPct = Math.round((m.context / maxContext) * 100);
    // card markup uses codingPct / contextPct as the bar widths
    return `...`;
  }).join('');
}

document.querySelectorAll('.priority-btn').forEach(btn => {
  btn.addEventListener('click', () => render(btn.dataset.sort));
});
Enter fullscreen mode Exit fullscreen mode

The ... in the card template is just markup. The complete file is at the bottom of this post.

Three things make this worth doing:

  1. Updating the tool means editing one array. There is no database or CMS field to change, and nothing to redeploy except the file.
  2. Sorting and re-ranking are free. render() rebuilds from the data every time, so a "priority" button is just a different sort key.
  3. Progress bars are derived values, not stored ones. Each bar is value / max * 100, computed at render time, so adding a new entry never breaks the scale.

Gotchas I ran into

Data goes stale, and nothing tells the reader. A comparison tool is only as good as its last update. I now put a visible "data snapshot" date at the top of the page. If the numbers are from August, the page says August.

Don't mix incomparable metrics in one bar. Two models can report coding scores from different benchmarks. The tool should label the benchmark next to each number and never imply a like-for-like comparison where there isn't one. That's why each entry carries its own codingLabel.

Inline styles in a .map() template get messy fast. Keep the visual rules in CSS classes and pass only the truly dynamic values (like a width percentage) inline.

Iframes and SEO. If the tool is embedded in an iframe, the content inside isn't treated as part of the host page's text. I put the explanation, methodology, and caveats in the surrounding article, and let the tool handle only the interactive part.

When this pattern is the wrong choice

  • You need user accounts, saved state across devices, or server-side logic
  • The data changes often enough that hand-editing an array becomes a chore (at that point, fetch from a JSON endpoint)
  • You need the tool to be crawlable as primary content

For a calculator or a comparison table that changes a few times a year, though, one file is hard to beat.

What I'd do differently next time

I'd move the data array into a separate data.json from the start. Keeping data and rendering in one file is convenient at first, but it makes updates feel like code changes when they are really content changes.

If you've embedded small tools in WordPress, I'd like to know how you handle keeping the data fresh. A manual update works for me now, but I doubt it scales.

Full source: the complete single file






<title>AI Model Compare — UniversalNest</title>



  :root{
    --bg: #F7F8FA;
    --surface: #FFFFFF;
    --line: #E2E5EA;
    --ink: #12151C;
    --muted: #6B7280;
    --signal: #FF4B3E;
    --signal-soft: #FFEDEB;
    --blue: #0047FF;
    --blue-soft: #EAF0FF;
    --focus: #0047FF;
  }
  *{box-sizing:border-box;}
  html,body{margin:0;padding:0;}
  body{
    background:var(--bg);
    color:var(--ink);
    font-family:'Inter',system-ui,sans-serif;
    -webkit-font-smoothing:antialiased;
    line-height:1.5;
  }
  .wrap{max-width:980px;margin:0 auto;padding:40px 20px 80px;}

  /* header */
  .eyebrow{
    font-family:'JetBrains Mono',monospace;
    font-size:12px;
    letter-spacing:0.08em;
    text-transform:uppercase;
    color:var(--muted);
    display:flex;
    align-items:center;
    gap:8px;
    margin-bottom:14px;
  }
  .eyebrow::before{
    content:"";
    width:7px;height:7px;border-radius:50%;
    background:var(--signal);
    display:inline-block;
  }
  h1{
    font-family:'Space Grotesk',sans-serif;
    font-weight:700;
    font-size:clamp(28px,4.2vw,42px);
    line-height:1.08;
    margin:0 0 12px;
    letter-spacing:-0.01em;
  }
  .sub{
    color:var(--muted);
    font-size:16px;
    max-width:56ch;
    margin:0 0 28px;
  }
  .updated{
    font-family:'JetBrains Mono',monospace;
    font-size:12px;
    color:var(--muted);
    border-top:1px solid var(--line);
    padding-top:14px;
    margin-bottom:32px;
  }

  /* priority selector */
  .priority-row{
    display:flex;
    flex-wrap:wrap;
    gap:8px;
    margin-bottom:36px;
  }
  .priority-btn{
    font-family:'Inter',sans-serif;
    font-size:13.5px;
    font-weight:500;
    padding:9px 16px;
    border-radius:999px;
    border:1px solid var(--line);
    background:var(--surface);
    color:var(--ink);
    cursor:pointer;
    transition:border-color .15s ease, background .15s ease;
  }
  .priority-btn:hover{border-color:var(--blue);}
  .priority-btn:focus-visible{outline:2px solid var(--focus);outline-offset:2px;}
  .priority-btn.active{
    background:var(--ink);
    border-color:var(--ink);
    color:#fff;
  }

  /* comparison grid */
  .grid{
    display:grid;
    grid-template-columns:repeat(auto-fit,minmax(210px,1fr));
    gap:14px;
  }
  .card{
    background:var(--surface);
    border:1px solid var(--line);
    border-radius:10px;
    padding:20px 18px 18px;
    position:relative;
    transition:transform .15s ease, box-shadow .15s ease, opacity .2s ease;
  }
  .card.rank-1{border-color:var(--signal);}
  .badge{
    position:absolute;
    top:-11px;
    left:16px;
    background:var(--signal);
    color:#fff;
    font-family:'JetBrains Mono',monospace;
    font-size:10.5px;
    letter-spacing:0.04em;
    text-transform:uppercase;
    padding:3px 8px;
    border-radius:5px;
  }
  .maker{
    font-family:'JetBrains Mono',monospace;
    font-size:11px;
    color:var(--muted);
    text-transform:uppercase;
    letter-spacing:0.04em;
  }
  .model-name{
    font-family:'Space Grotesk',sans-serif;
    font-weight:600;
    font-size:19px;
    margin:2px 0 16px;
    line-height:1.2;
  }
  .type-tag{
    display:inline-block;
    font-family:'JetBrains Mono',monospace;
    font-size:10.5px;
    padding:2px 7px;
    border-radius:4px;
    margin-bottom:14px;
  }
  .type-tag.open{background:var(--blue-soft);color:var(--blue);}
  .type-tag.closed{background:#F1F2F4;color:var(--muted);}

  .metric{margin-bottom:12px;}
  .metric-label{
    display:flex;
    justify-content:space-between;
    font-size:11.5px;
    color:var(--muted);
    margin-bottom:5px;
    font-family:'JetBrains Mono',monospace;
  }
  .metric-value{color:var(--ink);font-weight:600;}
  .gauge-track{
    height:5px;
    background:#EEF0F3;
    border-radius:3px;
    overflow:hidden;
  }
  .gauge-fill{
    height:100%;
    background:var(--ink);
    border-radius:3px;
  }
  .card.rank-1 .gauge-fill{background:var(--signal);}

  .price-row{
    display:flex;
    justify-content:space-between;
    align-items:baseline;
    font-family:'JetBrains Mono',monospace;
    font-size:13px;
    margin:16px 0 4px;
    padding-top:12px;
    border-top:1px solid var(--line);
  }
  .price-value{font-weight:600;font-size:15px;}

  .readmore{
    display:inline-block;
    margin-top:14px;
    font-size:13px;
    font-weight:500;
    color:var(--blue);
    text-decoration:none;
  }
  .readmore:hover{text-decoration:underline;}
  .readmore:focus-visible{outline:2px solid var(--focus);outline-offset:2px;}

  footer{
    margin-top:48px;
    padding-top:20px;
    border-top:1px solid var(--line);
    font-size:13px;
    color:var(--muted);
  }
  footer a{color:var(--blue);text-decoration:none;}
  footer a:hover{text-decoration:underline;}

  @media (prefers-reduced-motion: reduce){
    .card{transition:none;}
  }





  Live spec sheet
  <h1>Which AI model actually fits your job?</h1>
  <p>Four current frontier and open-weight models, lined up on the numbers that matter: coding benchmarks, price per million tokens, and context window. Pick a priority to re-rank.</p>
  DATA SNAPSHOT — AUG 2026 · SOURCED FROM UNIVERSALNEST MODEL COVERAGE


    Overview
    Best for coding
    Cheapest
    Largest context





    Full write-ups on each model, methodology, and benchmark caveats live on
    <a href="https://universalnest.com/category/ai-news-tech/ai-tools-productivity/" rel="noopener">UniversalNest — AI Tools &amp; Productivity</a>.
    Built by <a href="https://universalnest.com" rel="noopener">UniversalNest</a>.




const MODELS = [
  {
    maker: "OpenAI",
    name: "GPT-5.6 Sol",
    type: "closed",
    coding: 91.9,
    codingLabel: "91.9% Terminal-Bench",
    price: 30,
    priceLabel: "$30 / M tokens",
    context: 400,
    contextLabel: "400K tokens",
    url: "https://universalnest.com/gpt-claude-gemini-ai-model-comparison/"
  },
  {
    maker: "Anthropic",
    name: "Claude Opus 4.8",
    type: "closed",
    coding: 69.2,
    codingLabel: "69.2% SWE-bench Pro",
    price: 22,
    priceLabel: "$22 / M tokens",
    context: 500,
    contextLabel: "500K tokens",
    url: "https://universalnest.com/best-llms-for-coding/"
  },
  {
    maker: "Google",
    name: "Gemini 3.1 Pro",
    type: "closed",
    coding: 76.4,
    codingLabel: "76.4% SWE-bench Pro",
    price: 12,
    priceLabel: "$12 / M tokens",
    context: 1000,
    contextLabel: "1M tokens",
    url: "https://universalnest.com/gpt-claude-gemini-ai-model-comparison/"
  },
  {
    maker: "DeepSeek",
    name: "DeepSeek V4 Pro",
    type: "open",
    coding: 80.6,
    codingLabel: "80.6% SWE-bench",
    price: 0.87,
    priceLabel: "$0.87 / M tokens",
    context: 256,
    contextLabel: "256K tokens",
    url: "https://universalnest.com/deepseek-vs-gpt-coding-price-comparison/"
  }
];

const grid = document.getElementById('grid');
const maxCoding = Math.max(...MODELS.map(m =&gt; m.coding));
const maxContext = Math.max(...MODELS.map(m =&gt; m.context));
const minPrice = Math.min(...MODELS.map(m =&gt; m.price));

function render(sortKey){
  let list = [...MODELS];
  let rankField = null;
  let badgeText = "";

  if(sortKey === "coding"){
    list.sort((a,b)=&gt;b.coding-a.coding);
    rankField = "coding"; badgeText = "Top coding score";
  } else if(sortKey === "cheap"){
    list.sort((a,b)=&gt;a.price-b.price);
    rankField = "price-asc"; badgeText = "Lowest price";
  } else if(sortKey === "context"){
    list.sort((a,b)=&gt;b.context-a.context);
    rankField = "context"; badgeText = "Largest context";
  }

  grid.innerHTML = list.map((m, i) =&gt; {
    const isTop = rankField &amp;&amp; i === 0;
    const codingPct = Math.round((m.coding / maxCoding) * 100);
    const contextPct = Math.round((m.context / maxContext) * 100);
    return `
    &lt;div class="card ${isTop ? 'rank-1' : ''}"&gt;
      ${isTop ? `&lt;div class="badge"&gt;${badgeText}&lt;/div&gt;` : ``}
      &lt;div class="maker"&gt;${m.maker}&lt;/div&gt;
      &lt;div class="model-name"&gt;${m.name}&lt;/div&gt;
      &lt;span class="type-tag ${m.type}"&gt;${m.type === 'open' ? 'Open-weight' : 'Closed'}&lt;/span&gt;

      &lt;div class="metric"&gt;
        &lt;div class="metric-label"&gt;&lt;span&gt;Coding benchmark&lt;/span&gt;&lt;span class="metric-value"&gt;${m.codingLabel}&lt;/span&gt;&lt;/div&gt;
        &lt;div class="gauge-track"&gt;&lt;div class="gauge-fill" style="width:${codingPct}%"&gt;&lt;/div&gt;&lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="metric"&gt;
        &lt;div class="metric-label"&gt;&lt;span&gt;Context window&lt;/span&gt;&lt;span class="metric-value"&gt;${m.contextLabel}&lt;/span&gt;&lt;/div&gt;
        &lt;div class="gauge-track"&gt;&lt;div class="gauge-fill" style="width:${contextPct}%"&gt;&lt;/div&gt;&lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="price-row"&gt;
        &lt;span&gt;PRICE / M TOKENS&lt;/span&gt;
        &lt;span class="price-value"&gt;${m.priceLabel}&lt;/span&gt;
      &lt;/div&gt;

      &lt;a class="readmore" href="${m.url}" target="_blank" rel="noopener"&gt;Full breakdown on UniversalNest →&lt;/a&gt;
    &lt;/div&gt;`;
  }).join('');
}

document.querySelectorAll('.priority-btn').forEach(btn=&gt;{
  btn.addEventListener('click', ()=&gt;{
    document.querySelectorAll('.priority-btn').forEach(b=&gt;b.classList.remove('active'));
    btn.classList.add('active');
    render(btn.dataset.sort);
  });
});

render('default');



Enter fullscreen mode Exit fullscreen mode

Top comments (0)