The worst feedback on a freelance web project is not "can you change the colour of this button". It's the message that arrives ten minutes after you deliver: "the Contact link on the About page goes nowhere", or "why does the pricing page link to a staging address?" Nothing is really broken, but the client now reads every other page looking for the next mistake.

Most of those messages are about things a machine could have found. So before marking a project as delivered, it's worth running one script against the staging site. This one crawls every internal page and prints a short Markdown report: broken links, pages without a title or meta description, images without alt text, duplicate titles, insecure http:// assets, and leftover text like localhost, lorem ipsum, TODO or the staging hostname.

This post walks through that script. It's about 60 lines of plain JavaScript with no dependencies, it runs on Node 18 or newer (which has fetch built in), and you can change the rules in a minute.

What it checks, and what it doesn't

The checks are deliberately boring. Each one is something a client or their visitors would notice:

  • Broken internal links. Any internal URL that doesn't return a successful status.
  • Missing <title> or meta description. These are what show up in browser tabs, search results and link previews.
  • Images with no alt attribute at all. An empty alt="" is fine for decorative images, so the script only flags a missing attribute.
  • Duplicate titles. Usually a copy-pasted layout that never got its own title.
  • Insecure assets. An http:// image, script or stylesheet on an https:// page.
  • Leftovers. Text you never want a client to see: localhost, 127.0.0.1, lorem ipsum, TODO, plus any hostnames you pass in.

It doesn't run JavaScript, measure performance or replace a proper accessibility audit. Pages that build their content in the browser will look emptier to it than they do to a visitor. For a typical brochure site, landing page or blog theme, though, the server's HTML is what matters, and that's what it reads.

Reading attributes without a parser

To stay dependency-free, the script uses two small regex helpers instead of an HTML parser. tags() finds every opening tag with a given name, and attr() reads one attribute from a tag whether it's double-quoted, single-quoted or unquoted:

const attr = (tag, name) => { const m = tag.match(new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, 'i')); return m ? (m[1] ?? m[2] ?? m[3]) : null; }; const tags = (html, name) => html.match(new RegExp(`<${name}\\b[^>]*>`, 'gi')) ?? []; 

Regex HTML parsing has well-known limits: an attribute value containing > will confuse it, and so will tags inside comments. For a check that runs against your own templates, that trade-off is fine. If you need more, swap these two functions for a real parser such as parse5 and keep the rest.

Note that attr() returns null when an attribute is missing and an empty string when it's present but empty. That difference is what lets the alt-text rule tell "forgot the alt" from "decorative image".

The per-page checks

checkPage() takes the page's final URL and its HTML, and returns the title (for the duplicate check later) and a list of issues written as plain sentences:

function checkPage(url, html) { const issues = []; const title = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1].trim(); if (!title) issues.push('missing <title>'); const desc = tags(html, 'meta').find((t) => attr(t, 'name')?.toLowerCase() === 'description'); if (!desc || !attr(desc, 'content')?.trim()) issues.push('missing meta description'); for (const img of tags(html, 'img')) { if (attr(img, 'alt') === null) issues.push(`image without alt: ${attr(img, 'src')}`); } if (url.protocol === 'https:') { for (const t of [...tags(html, 'img'), ...tags(html, 'script'), ...tags(html, 'link')]) { const src = attr(t, 'src') ?? (attr(t, 'rel')?.includes('stylesheet') ? attr(t, 'href') : null); if (src?.startsWith('http://')) issues.push(`insecure asset (http://): ${src}`); } } const text = html.replace(/<script[\s\S]*?<\/script>/gi, ''); for (const word of LEFTOVERS) { if (text.toLowerCase().includes(word.toLowerCase())) issues.push(`leftover text: "${word}"`); } return { title, issues }; } 

The leftover search strips <script> blocks first, because analytics snippets and framework code often contain words like localhost legitimately. It searches the rest of the HTML, attributes included, which is the point: a staging hostname hiding in an href is exactly what we want to catch.

The TODO rule is case-insensitive, so a to-do list app will trip it on every page. Remove it from the list for projects like that.

The crawl

The crawl is a plain breadth-first loop. It starts at the URL you pass, follows only same-origin links, ignores mailto:, tel:, javascript: and in-page # links, strips fragments so /about#team and /about count as one page, and stops after --max pages so a calendar or a search page with endless links can't keep it running forever:

const queue = [start.href], seen = new Set(queue), pages = [], broken = [], titles = new Map(); while (queue.length && pages.length < MAX) { const href = queue.shift(); const res = await fetch(href, { redirect: 'follow' }).catch((e) => ({ ok: false, status: e.cause?.code ?? 'ERR' })); if (!res.ok) { broken.push({ href, status: res.status }); continue; } if (!res.headers.get('content-type')?.includes('text/html')) continue; const html = await res.text(); const url = new URL(res.url); const { title, issues } = checkPage(url, html); pages.push({ href: url.href, issues }); if (title) titles.set(title, [...(titles.get(title) ?? []), url.pathname]); for (const a of tags(html, 'a')) { const raw = attr(a, 'href'); if (!raw || /^(mailto:|tel:|javascript:|#)/i.test(raw)) continue; const next = new URL(raw, url); next.hash = ''; if (next.origin === start.origin && !seen.has(next.href)) { seen.add(next.href); queue.push(next.href); } } } 

Two details matter here. Relative links are resolved against res.url, the address after redirects, not the one we asked for, so a page that redirects from /about to /about/ resolves its links correctly. And network errors (a DNS failure, a refused connection) are turned into a "broken" entry with the error code instead of crashing the whole run.

External links aren't fetched at all. Checking them is slow, some sites block scripted requests, and a third-party page being down isn't something you can fix before delivery. If you want it, add a second pass with HEAD requests and a short timeout.

The report

The output is Markdown, so you can paste it straight into an issue, a pull request or your own notes. Sections only appear when they have something in them:

const lines = [`# Pre-delivery check: ${start.origin}`, '', `Checked ${pages.length} page(s).`, '']; if (broken.length) lines.push('## Broken links', ...broken.map((b) => `- ${b.href} (${b.status})`), ''); const dupes = [...titles].filter(([, p]) => p.length > 1); if (dupes.length) lines.push('## Duplicate titles', ...dupes.map(([t, p]) => `- "${t}": ${p.join(', ')}`), ''); const flagged = pages.filter((p) => p.issues.length); if (flagged.length) lines.push('## Page issues', ...flagged.flatMap((p) => [`- ${new URL(p.href).pathname}`, ...p.issues.map((i) => ` - ${i}`)]), ''); if (!broken.length && !dupes.length && !flagged.length) lines.push('No issues found.'); console.log(lines.join('\n')); process.exitCode = broken.length || dupes.length || flagged.length ? 1 : 0; 

The exit code is 1 whenever something is flagged, so the same script can run in CI and fail a build.

The whole script

Here it is in one piece, ready to save as predeliver.mjs:

// predeliver.mjs: crawl a staging site before handover and list what a client would notice. // Node 18+ (built-in fetch), no dependencies. Usage: // node predeliver.mjs https://staging.example.com --leftover=staging.example.com --max=60 const args = process.argv.slice(2); const start = new URL(args.find((a) => !a.startsWith('--'))); const opt = (name, def) => (args.find((a) => a.startsWith(`--${name}=`)) ?? `=${def}`).split('=').slice(1).join('='); const MAX = Number(opt('max', 50)); const LEFTOVERS = ['localhost', '127.0.0.1', 'lorem ipsum', 'TODO', ...opt('leftover', '').split(',').filter(Boolean)]; const attr = (tag, name) => { const m = tag.match(new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, 'i')); return m ? (m[1] ?? m[2] ?? m[3]) : null; }; const tags = (html, name) => html.match(new RegExp(`<${name}\\b[^>]*>`, 'gi')) ?? []; function checkPage(url, html) { const issues = []; const title = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1].trim(); if (!title) issues.push('missing <title>'); const desc = tags(html, 'meta').find((t) => attr(t, 'name')?.toLowerCase() === 'description'); if (!desc || !attr(desc, 'content')?.trim()) issues.push('missing meta description'); for (const img of tags(html, 'img')) { if (attr(img, 'alt') === null) issues.push(`image without alt: ${attr(img, 'src')}`); } if (url.protocol === 'https:') { for (const t of [...tags(html, 'img'), ...tags(html, 'script'), ...tags(html, 'link')]) { const src = attr(t, 'src') ?? (attr(t, 'rel')?.includes('stylesheet') ? attr(t, 'href') : null); if (src?.startsWith('http://')) issues.push(`insecure asset (http://): ${src}`); } } const text = html.replace(/<script[\s\S]*?<\/script>/gi, ''); for (const word of LEFTOVERS) { if (text.toLowerCase().includes(word.toLowerCase())) issues.push(`leftover text: "${word}"`); } return { title, issues }; } const queue = [start.href], seen = new Set(queue), pages = [], broken = [], titles = new Map(); while (queue.length && pages.length < MAX) { const href = queue.shift(); const res = await fetch(href, { redirect: 'follow' }).catch((e) => ({ ok: false, status: e.cause?.code ?? 'ERR' })); if (!res.ok) { broken.push({ href, status: res.status }); continue; } if (!res.headers.get('content-type')?.includes('text/html')) continue; const html = await res.text(); const url = new URL(res.url); const { title, issues } = checkPage(url, html); pages.push({ href: url.Source:https://dev.to/abidali671/a-pre-delivery-check-for-freelance-web-projects-one-node-script-for-broken-links-missing-alt-text-5cdk

Related posts