DEV Community

Cover image for A Pre-Delivery Check for Freelance Web Projects: One Node Script for Broken Links, Missing Alt Text and Leftover Staging URLs
Abid Ali
Abid Ali

Posted on

A Pre-Delivery Check for Freelance Web Projects: One Node Script for Broken Links, Missing Alt Text and Leftover Staging URLs

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')) ?? [];
Enter fullscreen mode Exit fullscreen mode

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 };
}
Enter fullscreen mode Exit fullscreen mode

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); }
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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.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); }
  }
}

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;
Enter fullscreen mode Exit fullscreen mode

Running it

Point it at the staging site and pass the staging hostname as a leftover, so any absolute link that still mentions it is caught once the site moves to the real domain:

node predeliver.mjs https://staging.example.com --leftover=staging.example.com --max=60
Enter fullscreen mode Exit fullscreen mode

Here it is tested against a tiny two-page fixture site served locally with python3 -m http.server. The home page had an image without alt text, a TODO paragraph, a link to a missing /contact page and an absolute link to a fake staging hostname; the About page reused the home page's title and had no description. This is the report it printed, with exit code 1:

# Pre-delivery check: http://127.0.0.1:8765

Checked 2 page(s).

## Broken links
- http://127.0.0.1:8765/contact (404)

## Duplicate titles
- "Acme Tiles": /, /about/

## Page issues
- /
  - image without alt: /hero.jpg
  - leftover text: "TODO"
  - leftover text: "staging.acme-test.local"
- /about/
  - missing meta description
Enter fullscreen mode Exit fullscreen mode

The About page's alt="" team photo wasn't flagged, as intended. A clean page in the same folder produced "No issues found." and exit code 0.

One caveat: if your staging site is password-protected, fetch will get the login page or a 401. Either run the check from a machine that can reach staging without the password, or add an Authorization header to the fetch call for that run only, and keep the credentials out of the script.

Where it fits in a freelance workflow

There are two good moments to run it: before you tell the client the work is ready for review, and again after the site moves to its real domain. The second run is the one that catches staging links, because they only become wrong after the move.

How you hand work over depends on where the client found you. On Upwork, delivery usually lines up with a contract milestone the client reviews and approves; on Fiverr, you deliver an order and the buyer either accepts it or asks for a revision. If you're new to either, Digital Dialogue has plain explainers of how Upwork works and how Fiverr works, step by step, from profile to payment. Either way, the moment you click "submit" or "deliver" is the moment the client starts clicking around, and a clean report is a good thing to have done just before it.

The report itself doesn't need to go in the delivery message. Clients don't need a list of things you fixed. A short note saying which pages you checked and how is enough, and it reads much better than a defensive reply to "this link is broken" an hour later.


I'm Abid Ali, a freelance full-stack developer in Karachi. I write practical guides on freelancing platforms, proposals and client work on Digital Dialogue.

Top comments (0)