• Sep 8, 2026
  • 6 min read

A Client-Ready HTML Report Without AI

The first “full report” my audit tool ever produced, I wrote by hand. I’d met a small studio owner, ran the audit on their site, and turned the raw scores into a one-page HTML document: a verdict, the overall score, four category scores, three hard numbers, why they matter, three numbered priorities, and my name at the bottom.

People liked it far more than the dashboard. So when I built the emailed full report, that one-pager became the template. The question was how to fill it in for every site automatically. The obvious answer in 2026 is a language model. I didn’t use one.

The structure, kept from the hand-made version

Report sectionRule that fills it
Headline (“Clear wins for search visibility and security”)The two lowest category scores below 80, mapped to plain names
Verdict sentenceOne of three templates by score band: 80+, 50–79, below 50
Score card + four tilesThe measured scores; a check that didn’t run shows ”— not checked”
The most importantThresholds on real numbers, worst first, max six
Why it mattersOne business sentence per problem type found
Recommended prioritiesCandidates ranked by severity, top three, with real sizes quoted
Broken links tableFirst ten from the crawler: link, page it’s on, error
Call to action”See how I’d fix this” → my consulting qualifier
flowchart LR
  R[Audit results: scores, Lighthouse metrics, findings, crawl] --> T[Thresholds and rankings]
  T --> H[Headline and verdict]
  T --> F[The most important]
  T --> W[Why it matters]
  T --> P[Three priorities]
  R --> B[Broken links table]
  H & F & W & P & B --> X[One self-contained HTML page]

Every sentence comes from a number

The core is one function, buildReportContent, about a hundred lines of plain JavaScript. The “most important” list is a set of thresholds:

const facts = [];
if (lh.lcp_ms > 2500) facts.push(`The main content takes about <strong>${seconds(lh.lcp_ms)}</strong> to appear on mobile (Google wants under 2.5s).`);
if (lh.tbt_ms > 300) facts.push(`The browser is blocked by JavaScript for <strong>${seconds(lh.tbt_ms)}</strong> while the page loads.`);
if (weight.total_kb > 2500) facts.push(`The page weighs about <strong>${megabytes(weight.total_kb)}</strong> — heavy on a phone connection.`);
if (brokenCount > 0) facts.push(`The crawl found <strong>${brokenCount} broken link${brokenCount > 1 ? 's' : ''}</strong>.`);

A certificate expiring within 30 days, and high-severity findings from the security, SEO and trust checks (how findings become sentences), fill the rest.

The priorities are candidates with a rank, so the most urgent problem always comes first. HTTPS problems outrank speed, speed outranks broken links, then search basics, trust, and headers:

if (secHigh.length) candidates.push({ rank: 0, title: 'Fix HTTPS before anything else', … });
if (lh.lcp_ms > 4000 || (r.breakdown?.performance ?? 100) < 50) {
    const bits = [];
    if (weight.image_kb > 500) bits.push(`compress and resize images (${megabytes(weight.image_kb)} today)`);
    if (weight.js_kb > 300) bits.push(`defer or remove JavaScript from themes and plugins (${megabytes(weight.js_kb)} today)`);
    candidates.push({ rank: 1, title: 'Speed up the mobile load', … });
}
const priorities = candidates.sort((a, b) => a.rank - b.rank).slice(0, 3);

That’s what makes the advice feel specific: “compress and resize images (5.1MB today)” is a sentence a business owner can forward to whoever builds their site, and the number in it is real.

One page, nothing to load

The output is one HTML file: inline CSS, no JavaScript, no web fonts, no tracking pixel, only my own icon as an asset. It adapts to a phone with one media query, carries noindex, and is served from the audit server for 7 days. It looks the same in a browser, printed to PDF, or opened by someone on a slow connection a week later.

Why not an LLM?

I considered it seriously. A model would write warmer, more varied prose. But for this document the trade-offs point the other way:

Rules + templateLLM-written report
AccuracyBy construction: every number is measuredCan misquote a number or invent a problem
PrivacyNo new processorSite data goes to a model provider (privacy policy change)
Cost and speedFree, instantPer-report cost, seconds to minutes
ConsistencySame site → same reportVaries run to run
TestingPlain unit tests on fixturesHard to assert on prose
ToneGeneric, a bit formalNatural, personal

The report exists to show a business owner a real problem. One wrong claim in it (“your site has no HTTPS” when it does) costs more credibility than a hundred well-phrased paragraphs earn. Rules can be boring; they can’t hallucinate.

The trade-off, and what I’d add later

The honest downside is tone. My hand-made version opened with a personal line about the conversation we’d had; the generated one can’t. Two ways to close that gap without giving up accuracy:

  1. An AI-written opening paragraph only. The model gets the computed facts (score, verdict, priorities) and writes two or three warm sentences. Every number it mentions is checked against the data before sending; the rest of the report stays rule-based. That still means a new processor in the privacy policy.
  2. A human pass for warm leads. Hold the report for people I’ve actually spoken to, add a personal note, then send. At today’s volume, that’s the one I’d pick.

The rules also needed one fix after real use: a link checker that failed to run was being scored as zero, which made the headline blame “broken links”. That story is in the next post.

Try it on your own site at webaudit.jalcocertech.com — tick “email me the full report”.

Takeaways

  • Start from a document a human wrote and people liked, then make its structure the template.
  • Tie every sentence to a measured number with an explicit threshold; quote the number in the sentence.
  • Rank recommendations explicitly so the most urgent one is always first.
  • Use a model for tone, not for facts, if at all — and verify its numbers.

Next: a failed check is not a bad score.