diff --git a/CHANGELOG.md b/CHANGELOG.md index e55fa7a..42ab514 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # check-http-status changelog +## 2.2.0 - Unreleased + +### New + +- Crawled pages record the first `h1`, the number of H1s (`h1Count`) and the `metaDescription` +- Assets record where they came from (`assetType`: `image`, `script`, `stylesheet`, `icon`, `media` or `iframe`) +- Images record their `alt` texts and `title` attributes, per page and in total, and `missingAlt` when an `` has no `alt` attribute +- Excel and CSV reports have Asset Type, H1, # H1, Meta Description, Alt Text, Image Title and Missing Alt columns +- HTML report: filters for images, scripts, stylesheets, missing title, missing or multiple H1, missing meta description and missing alt text + +### Fixed + +- HTML report: search only looked inside the selected filter (*All issues* by default), so a URL without an issue was never found. Filter counts now follow the search, and a search with no matches in the selected filter offers to show all matches +- HTML report: search no longer matches every link on a page whose URL contains the search text; it looks in the URL, redirect, title, H1, link text and alt text + ## 2.1.0 - Oct 2, 2026 ### New diff --git a/README.md b/README.md index 9492e6a..03dd1a6 100644 --- a/README.md +++ b/README.md @@ -238,8 +238,10 @@ await writeReport(report, 'report.xlsx', 'xlsx'); | `error` | Connection error (timeout, DNS, TLS…) if the request failed. | | `type`, `linkType`, `scope` | `internal`/`external`, `page`/`asset`, and whether it was crawled, excluded, checked or skipped. | | `redirectTo`, `redirectChain`, `finalUrl`, `finalStatus`, `redirectCount` | Redirect details. `note` is set for loops or too many redirects. | -| `foundOn` | `[{ page, anchors, count, kind }]`, where `kind` is `link`, `redirect` or `sitemap`. | -| `title`, `contentType`, `responseTime`, `depth`, `inSitemap`, `orphan` | Page details. | +| `foundOn` | `[{ page, anchors, count, kind }]`, where `kind` is `link`, `redirect` or `sitemap`. Images also have `alt`, `imageTitle` and `missingAlt` for that page. | +| `title`, `h1`, `h1Count`, `metaDescription` | Crawled pages: ``, the first `<h1>` and how many there are, and the meta description. | +| `assetType`, `alt`, `imageTitle`, `missingAlt` | Assets: `image`, `script`, `stylesheet`, `icon`, `media` or `iframe`. Images: every alt text and `title` used for it, and whether an `<img>` has no `alt` attribute (`alt=""` counts as set). | +| `contentType`, `responseTime`, `depth`, `inSitemap`, `orphan` | Page details. | ## Config file diff --git a/index.d.ts b/index.d.ts index df79f9a..c1684f2 100644 --- a/index.d.ts +++ b/index.d.ts @@ -151,6 +151,12 @@ declare namespace checkHttpStatus { /** Times the link appears on that page. */ count: number; kind: 'link' | 'redirect' | 'sitemap' | 'seed'; + /** Images only: `alt` texts used on that page. */ + alt?: string[]; + /** Images only: `title` attributes used on that page. */ + imageTitle?: string[]; + /** Images only: `<img>` tags on that page without an `alt` attribute. */ + missingAlt?: number; } interface RedirectHop { @@ -167,6 +173,8 @@ declare namespace checkHttpStatus { error: string; type: 'internal' | 'external'; linkType: 'page' | 'asset'; + /** Assets only: the tag it was found in. */ + assetType: '' | 'image' | 'script' | 'stylesheet' | 'icon' | 'media' | 'iframe'; scope: Scope; redirectTo: string; redirectChain: RedirectHop[]; @@ -179,6 +187,15 @@ declare namespace checkHttpStatus { note: string; foundOn: FoundOn[]; title: string; + /** Crawled pages: first `<h1>` and how many there are. */ + h1: string; + h1Count: number; + metaDescription: string; + /** Images: every `alt` text and `title` attribute used for it. */ + alt: string[]; + imageTitle: string[]; + /** Images: some `<img>` has no `alt` attribute (`alt=""` is fine). */ + missingAlt: boolean; contentType: string; responseTime: number | null; depth: number; diff --git a/lib/checker.js b/lib/checker.js index ed3ef5e..2ac8b94 100644 --- a/lib/checker.js +++ b/lib/checker.js @@ -190,7 +190,8 @@ class Checker { return { depth, 'scope': 'crawl' }; } - discover(raw, { from = null, fromUrl = null, kind = 'link', anchor = '', base, isAsset = false, seed = null } = {}) { + discover(raw, { from = null, fromUrl = null, kind = 'link', anchor = '', base, assetType = '', image = null, seed = null } = {}) { + const isAsset = !!assetType; const url = this.normalize(raw, base || (from && from.url) || undefined); if (!url) { return; @@ -206,13 +207,17 @@ class Checker { } record = { + 'assetType': assetType, 'contentType': '', 'crawled': false, 'depth': depth, 'error': '', + 'h1': '', + 'h1Count': 0, 'internal': this.isInternal(url), 'isStart': seed === 'crawl', 'linkType': isAsset ? 'asset' : 'page', + 'metaDescription': '', 'note': '', 'queued': false, 'redirectHops': kind === 'redirect' ? from.redirectHops + 1 : 0, @@ -249,6 +254,18 @@ class Checker { if (anchor) { source.anchors.add(anchor); } + + if (image) { + source.alt = source.alt || new Set(); + source.imageTitle = source.imageTitle || new Set(); + source.missingAlt = (source.missingAlt || 0) + (image.alt === null ? 1 : 0); + if (image.alt) { + source.alt.add(image.alt); + } + if (image.title) { + source.imageTitle.add(image.title); + } + } } } @@ -434,8 +451,8 @@ class Checker { this.discover(target.href, { 'anchor': '(redirect)', + 'assetType': record.assetType, 'from': record, - 'isAsset': record.linkType === 'asset', 'kind': 'redirect' }); return; @@ -450,7 +467,12 @@ class Checker { extractLinks(record, html) { const $ = cheerio.load(html); - record.title = $('title').first().text().replace(/\s+/g, ' ').trim().slice(0, 300); + const text = (node) => node.text().replace(/\s+/g, ' ').trim().slice(0, 300); + record.title = text($('title').first()); + record.h1 = text($('h1').first()); + record.h1Count = $('h1').length; + record.metaDescription = ($('meta[name]').filter((_, el) => $(el).attr('name').toLowerCase() === 'description').first().attr('content') || '') + .replace(/\s+/g, ' ').trim().slice(0, 500); let base = record.url; const baseHref = $('base[href]').attr('href'); @@ -489,11 +511,19 @@ class Checker { for (const [selector, attr, label] of assets) { $(selector).each((_, el) => { - this.discover($(el).attr(attr), { + const $el = $(el); + // `alt=""` marks a decorative image; only a missing attribute counts as missing. + const image = label === 'image' ? { + 'alt': $el.attr('alt') === undefined ? null : $el.attr('alt').replace(/\s+/g, ' ').trim().slice(0, 300), + 'title': ($el.attr('title') || '').replace(/\s+/g, ' ').trim().slice(0, 300) + } : null; + + this.discover($el.attr(attr), { 'anchor': `(${label})`, + 'assetType': label, 'base': base, 'from': record, - 'isAsset': true + image }); }); } @@ -573,15 +603,27 @@ class Checker { toResult(record) { const { chain, loop } = record.redirectTo ? this.redirectChain(record) : { 'chain': [], 'loop': false }; const final = chain[chain.length - 1]; - const foundOn = [...record.sources].map(([page, source]) => ({ - 'anchors': [...source.anchors], - 'count': source.count, - 'kind': source.kind, - 'page': page - })); + const foundOn = [...record.sources].map(([page, source]) => { + const entry = { + 'anchors': [...source.anchors], + 'count': source.count, + 'kind': source.kind, + 'page': page + }; + if (source.alt) { + entry.alt = [...source.alt]; + entry.imageTitle = [...source.imageTitle]; + entry.missingAlt = source.missingAlt; + } + + return entry; + }); + const unique = (key) => [...new Set(foundOn.flatMap((source) => source[key] || []))]; const inSitemap = foundOn.some((source) => source.kind === 'sitemap'); return { + 'alt': unique('alt'), + 'assetType': record.assetType, 'botProtection': record.botProtection || '', 'category': categorize(record), 'contentType': record.contentType, @@ -591,8 +633,13 @@ class Checker { 'finalStatus': final ? final.status : null, 'finalUrl': final ? final.url : '', 'foundOn': foundOn, + 'h1': record.h1, + 'h1Count': record.h1Count, + 'imageTitle': unique('imageTitle'), 'inSitemap': inSitemap, 'linkType': record.linkType, + 'metaDescription': record.metaDescription, + 'missingAlt': foundOn.some((source) => source.missingAlt > 0), 'note': loop ? 'Redirect loop' : record.note, 'orphan': this.crawlMode && inSitemap && !record.isStart && !this.isLinked(record), 'redirectChain': chain, diff --git a/lib/report/csv.js b/lib/report/csv.js index e8f902d..a3388fa 100644 --- a/lib/report/csv.js +++ b/lib/report/csv.js @@ -11,11 +11,18 @@ const COLUMNS = [ ['category', 'Category'], ['type', 'Type'], ['linkType', 'Link Type'], + ['assetType', 'Asset Type'], ['scope', 'Crawl'], ['redirectCount', '# Redirects'], ['finalUrl', 'Final URL'], ['finalStatus', 'Final Status'], ['title', 'Title'], + ['h1', 'H1'], + ['h1Count', '# H1'], + ['metaDescription', 'Meta Description'], + ['alt', 'Alt Text'], + ['imageTitle', 'Image Title'], + ['missingAlt', 'Missing Alt'], ['contentType', 'Content Type'], ['depth', 'Depth'], ['responseTime', 'Response (ms)'], diff --git a/lib/report/excel.js b/lib/report/excel.js index 861ac6d..ef7ea1d 100644 --- a/lib/report/excel.js +++ b/lib/report/excel.js @@ -31,11 +31,18 @@ const URL_COLUMNS = [ CATEGORY, { 'header': 'Type', 'key': 'type', 'width': 10 }, { 'header': 'Link Type', 'key': 'linkType', 'width': 10 }, + { 'header': 'Asset Type', 'key': 'assetType', 'width': 11 }, { 'header': 'Crawl', 'key': 'scope', 'width': 22 }, { 'header': '# Redirects', 'key': 'redirectCount', 'width': 11 }, FINAL_URL, FINAL_STATUS, { 'header': 'Title', 'key': 'title', 'width': 40 }, + { 'header': 'H1', 'key': 'h1', 'width': 40 }, + { 'header': '# H1', 'key': 'h1Count', 'width': 6 }, + { 'header': 'Meta Description', 'key': 'metaDescription', 'width': 60 }, + { 'header': 'Alt Text', 'key': 'alt', 'width': 40 }, + { 'header': 'Image Title', 'key': 'imageTitle', 'width': 30 }, + { 'header': 'Missing Alt', 'key': 'missingAlt', 'width': 11 }, { 'header': 'Content Type', 'key': 'contentType', 'width': 20 }, { 'header': 'Depth', 'key': 'depth', 'width': 7 }, { 'header': 'Response (ms)', 'key': 'responseTime', 'width': 13 }, diff --git a/lib/report/rows.js b/lib/report/rows.js index b566ac1..cd4839a 100644 --- a/lib/report/rows.js +++ b/lib/report/rows.js @@ -44,8 +44,13 @@ function finalUrlLabel(result) { const byTypeThenUrl = (a, b) => (a.type === b.type ? 0 : a.type === 'internal' ? -1 : 1) || a.url.localeCompare(b.url); const byIssueThenUrl = (a, b) => ISSUE_ORDER.indexOf(a.category) - ISSUE_ORDER.indexOf(b.category) || a.url.localeCompare(b.url); +// Older reports have no SEO fields. +const list = (values) => (values || []).join(' | '); + function toRow(result) { return { + 'alt': list(result.alt), + 'assetType': result.assetType || '', 'category': result.category, 'contentType': result.contentType, 'depth': result.depth, @@ -53,8 +58,13 @@ function toRow(result) { 'finalUrl': finalUrlLabel(result), 'foundOn': foundOnLabel(result), 'foundOnCount': result.foundOn.length, + 'h1': result.h1 || '', + 'h1Count': result.crawled ? result.h1Count || 0 : '', + 'imageTitle': list(result.imageTitle), 'inSitemap': result.inSitemap ? 'Yes' : '', 'linkType': result.linkType, + 'metaDescription': result.metaDescription || '', + 'missingAlt': result.missingAlt ? 'Yes' : '', 'orphan': result.orphan ? 'Yes' : '', 'redirectCount': result.redirectCount || '', 'redirectTo': result.redirectTo, diff --git a/lib/report/template.html b/lib/report/template.html index 8555c1d..aeb45b1 100644 --- a/lib/report/template.html +++ b/lib/report/template.html @@ -60,6 +60,7 @@ .anchor { color: var(--muted); } .pager { display: flex; gap: 8px; align-items: center; justify-content: flex-end; margin-top: 10px; color: var(--muted); } .empty { text-align: center; color: var(--muted); padding: 30px; } + .empty button { margin-top: 8px; } footer { color: var(--muted); font-size: 12px; text-align: center; margin-top: 24px; } </style> </head> @@ -72,7 +73,7 @@ <h1 id="title">HTTP status report</h1> <section class="card"> <div class="toolbar"> - <input id="search" type="search" placeholder="Filter by URL, link text or page…" aria-label="Filter results"> + <input id="search" type="search" placeholder="Search URL, title, H1, link or alt text…" aria-label="Filter results"> <div> <button type="button" id="csvBtn">Download CSV (current view)</button> <button type="button" id="jsonBtn">Download JSON</button> @@ -126,11 +127,48 @@ <h1 id="title">HTTP status report</h1> ['external', 'External', function (r) { return r.type === 'external'; }], ['excluded', 'Excluded', function (r) { return r.scope === 'excluded'; }], ['asset', 'Assets', function (r) { return r.linkType === 'asset'; }], + ['image', 'Images', function (r) { return assetType(r) === 'image'; }], + ['script', 'Scripts', function (r) { return assetType(r) === 'script'; }], + ['stylesheet', 'Stylesheets', function (r) { return assetType(r) === 'stylesheet'; }], + ['noTitle', 'Missing title', function (r) { return isPage(r) && !r.title; }], + ['noH1', 'Missing H1', function (r) { return isPage(r) && r.h1Count === 0; }], + ['multiH1', 'Multiple H1', function (r) { return isPage(r) && r.h1Count > 1; }], + ['noDescription', 'Missing meta description', function (r) { return isPage(r) && r.h1Count !== undefined && !r.metaDescription; }], + ['noAlt', 'Missing alt text', function (r) { return r.missingAlt; }], ['orphan', 'Orphan pages', function (r) { return r.orphan; }], ['robots', 'Blocked by robots.txt', function (r) { return r.scope === 'robots'; }], ['notchecked', 'Not checked', function (r) { return r.category === 'Not checked'; }] ]; var PAGE_SIZE = 100; + + function isPage(r) { return r.crawled && r.linkType === 'page' && r.category === 'OK'; } + // Reports from before 2.2 have no assetType; the link text says "(image)". + function assetType(r) { + if (r.linkType !== 'asset') return ''; + if (r.assetType) return r.assetType; + for (var i = 0; i < r.foundOn.length; i++) { + var m = r.foundOn[i].anchors.join(' ').match(/\((image|script|stylesheet|icon|media|iframe)\)/); + if (m) return m[1]; + } + return ''; + } + + // The text a search looks in. Pages a URL was found on are left out: they would match every link on a page. + function matches(r, q) { + if (!q) return true; + var fields = [r.url, r.redirectTo, r.finalUrl, r.title, r.h1].concat(r.alt || [], r.imageTitle || []); + for (var i = 0; i < fields.length; i++) { + if (fields[i] && String(fields[i]).toLowerCase().indexOf(q) !== -1) return true; + } + return r.foundOn.some(function (s) { + return s.anchors.some(function (a) { return a.toLowerCase().indexOf(q) !== -1; }); + }); + } + + function searched() { + var q = state.query.toLowerCase(); + return q ? results.filter(function (r) { return matches(r, q); }) : results; + } var state = { filter: countOf('issues') ? 'issues' : 'all', query: '', page: 0, sort: null, dir: 1 }; var expanded = {}; @@ -167,16 +205,7 @@ <h1 id="title">HTTP status report</h1> function filtered() { var fn = FILTERS.filter(function (f) { return f[0] === state.filter; })[0][2]; - var q = state.query.toLowerCase(); - var rows = results.filter(fn); - if (q) { - rows = rows.filter(function (r) { - if (r.url.toLowerCase().indexOf(q) !== -1 || (r.redirectTo || '').toLowerCase().indexOf(q) !== -1) return true; - return r.foundOn.some(function (s) { - return s.page.toLowerCase().indexOf(q) !== -1 || s.anchors.some(function (a) { return a.toLowerCase().indexOf(q) !== -1; }); - }); - }); - } + var rows = searched().filter(fn); if (state.sort) { var val = { status: function (r) { return r.status == null ? (r.error ? 999 : 1000) : r.status; }, @@ -206,7 +235,8 @@ <h1 id="title">HTTP status report</h1> if (r.error) sub += '<div class="sub">' + esc(r.error) + '</div>'; var tags = '<span class="tag">' + esc(r.type) + '</span><span class="tag">' + esc(SCOPES[r.scope] || r.scope) + '</span>'; - if (r.linkType === 'asset') tags += '<span class="tag">asset</span>'; + if (r.linkType === 'asset') tags += '<span class="tag">' + esc(assetType(r) || 'asset') + '</span>'; + if (r.missingAlt) tags += '<span class="tag">no alt</span>'; if (r.inSitemap) tags += '<span class="tag">sitemap</span>'; if (r.orphan) tags += '<span class="tag">orphan</span>'; @@ -228,12 +258,17 @@ <h1 id="title">HTTP status report</h1> detail += '<h4>Found on</h4><ul>' + r.foundOn.map(function (s) { var via = s.kind === 'redirect' ? ' <span class="tag">redirect</span>' : s.kind === 'sitemap' ? ' <span class="tag">sitemap</span>' : ''; var anchors = s.anchors.filter(function (a) { return a.charAt(0) !== '('; }); + var alt = s.alt ? (s.alt.length ? ' <span class="anchor">— alt “' + esc(s.alt.join('”, “')) + '”</span>' : '') + (s.missingAlt ? ' <span class="tag">no alt</span>' : '') : ''; return '<li><a href="' + esc(s.page) + '" target="_blank" rel="noopener noreferrer">' + esc(s.page) + '</a>' + via + - (anchors.length ? ' <span class="anchor">— “' + esc(anchors.join('”, “')) + '”</span>' : '') + + (anchors.length ? ' <span class="anchor">— “' + esc(anchors.join('”, “')) + '”</span>' : '') + alt + (s.count > 1 ? ' <span class="tag">×' + s.count + '</span>' : '') + '</li>'; }).join('') + '</ul>'; } if (r.title) detail += '<div class="sub">Title: ' + esc(r.title) + '</div>'; + if (r.h1) detail += '<div class="sub">H1: ' + esc(r.h1) + (r.h1Count > 1 ? ' (' + r.h1Count + ' H1s)' : '') + '</div>'; + if (r.metaDescription) detail += '<div class="sub">Meta description: ' + esc(r.metaDescription) + '</div>'; + if (r.alt && r.alt.length) detail += '<div class="sub">Alt text: “' + esc(r.alt.join('”, “')) + '”</div>'; + if (r.imageTitle && r.imageTitle.length) detail += '<div class="sub">Image title: “' + esc(r.imageTitle.join('”, “')) + '”</div>'; if (r.responseTime != null) detail += '<div class="sub">Response: ' + r.responseTime + ' ms' + (r.contentType ? ' · ' + esc(r.contentType) : '') + '</div>'; html += '<tr class="detail"><td></td><td colspan="3">' + detail + '</td></tr>'; } @@ -241,8 +276,9 @@ <h1 id="title">HTTP status report</h1> } function render() { + var pool = searched(); $('chips').innerHTML = FILTERS.map(function (f) { - var n = results.filter(f[2]).length; + var n = pool.filter(f[2]).length; if (!n && ['all', 'issues', state.filter].indexOf(f[0]) === -1) return ''; return '<button type="button" class="chip' + (f[0] === state.filter ? ' active' : '') + '" data-filter="' + esc(f[0]) + '">' + esc(f[1]) + '<small>' + n.toLocaleString() + '</small></button>'; }).join(''); @@ -252,7 +288,12 @@ <h1 id="title">HTTP status report</h1> if (state.page >= pages) state.page = pages - 1; var slice = rows.slice(state.page * PAGE_SIZE, (state.page + 1) * PAGE_SIZE); - $('tbody').innerHTML = slice.length ? slice.map(rowHtml).join('') : '<tr><td colspan="4" class="empty">No URLs match this filter.</td></tr>'; + var empty = 'No URLs match this filter.'; + // The search found URLs, just not under the selected filter. + if (state.query && state.filter !== 'all' && pool.length) { + empty = 'No matches in this filter.<br><button type="button" data-filter="all">Show all ' + pool.length.toLocaleString() + ' match' + (pool.length === 1 ? '' : 'es') + '</button>'; + } + $('tbody').innerHTML = slice.length ? slice.map(rowHtml).join('') : '<tr><td colspan="4" class="empty">' + empty + '</td></tr>'; $('pager').innerHTML = rows.length > PAGE_SIZE ? '<button type="button" data-page="-1"' + (state.page === 0 ? ' disabled' : '') + '>‹ Prev</button><span>Page ' + (state.page + 1) + ' of ' + pages + ' · ' + rows.length.toLocaleString() + ' URLs</span><button type="button" data-page="1"' + (state.page >= pages - 1 ? ' disabled' : '') + '>Next ›</button>' : '<span>' + rows.length.toLocaleString() + ' URLs</span>'; @@ -273,10 +314,10 @@ <h1 id="title">HTTP status report</h1> } $('csvBtn').addEventListener('click', function () { - var lines = [['URL', 'Found On', 'Redirect To', 'Status', 'Category', 'Type', 'Final URL', 'Final Status'].join(',')]; + var lines = [['URL', 'Found On', 'Redirect To', 'Status', 'Category', 'Type', 'Final URL', 'Final Status', 'Title', 'H1', 'Meta Description', 'Alt Text'].join(',')]; filtered().forEach(function (r) { var foundOn = r.foundOn.map(function (s) { return s.page; }).join('\n'); - lines.push([r.url, foundOn, r.redirectTo, r.error || r.status, r.category, r.type, r.finalUrl, r.finalStatus].map(csvCell).join(',')); + lines.push([r.url, foundOn, r.redirectTo, r.error || r.status, r.category, r.type, r.finalUrl, r.finalStatus, r.title, r.h1, r.metaDescription, (r.alt || []).join(' | ')].map(csvCell).join(',')); }); download('http-status-report.csv', 'text/csv', '\ufeff' + lines.join('\r\n')); }); @@ -301,6 +342,7 @@ <h1 id="title">HTTP status report</h1> render(); }); $('tbody').addEventListener('click', function (e) { + if (e.target.closest('[data-filter]')) return setFilter(e); if (e.target.closest('a')) return; var tr = e.target.closest('tr.row'); if (!tr) return; diff --git a/package-lock.json b/package-lock.json index b17484b..d4c8ea6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "check-http-status", - "version": "2.1.0", + "version": "2.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "check-http-status", - "version": "2.1.0", + "version": "2.2.0", "license": "MIT", "dependencies": { "cheerio": "^1.0.0", diff --git a/package.json b/package.json index 11bbc7e..58dea81 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "check-http-status", "description": "Crawl a website or check a list of URLs: HTTP status codes, redirect chains, broken links and the pages they were found on. Export to Excel, CSV, JSON or an HTML report.", - "version": "2.1.0", + "version": "2.2.0", "main": "index.js", "types": "index.d.ts", "bin": { diff --git a/test/checker.test.js b/test/checker.test.js index 9cb8b41..060f0a5 100644 --- a/test/checker.test.js +++ b/test/checker.test.js @@ -118,6 +118,37 @@ describe('crawl mode', () => { assert.strictEqual(image.linkType, 'asset'); assert.strictEqual(image.status, 404); assert.strictEqual(find(report, '/assets/style.css').status, 200); + assert.strictEqual(find(report, '/assets/style.css').assetType, 'stylesheet'); + assert.strictEqual(find(report, '/favicon.ico').assetType, 'icon'); + assert.strictEqual(find(report, '/').assetType, ''); + }); + + test('records H1 and meta description for crawled pages', () => { + const home = find(report, '/'); + assert.strictEqual(home.h1, 'Gear for every trail'); + assert.strictEqual(home.h1Count, 1); + assert.strictEqual(home.metaDescription, 'Tents, backpacks and stoves.'); + + const about = find(report, '/about-us'); + assert.strictEqual(about.h1, 'About'); + assert.strictEqual(about.h1Count, 2); + assert.strictEqual(about.metaDescription, ''); + assert.strictEqual(find(report, '/privacy').h1Count, 0); + }); + + test('records alt text and title for images, per page', () => { + const hero = find(report, '/images/hero.jpg'); + assert.strictEqual(hero.assetType, 'image'); + assert.deepStrictEqual(hero.alt, ['Hero']); + assert.deepStrictEqual(hero.imageTitle, ['Hero banner']); + assert.strictEqual(hero.missingAlt, true); + + const onHome = hero.foundOn.find((source) => source.page === site.origin + '/'); + assert.deepStrictEqual([onHome.alt, onHome.missingAlt], [['Hero'], 0]); + const onAbout = hero.foundOn.find((source) => source.page === site.origin + '/about-us'); + assert.deepStrictEqual([onAbout.alt, onAbout.imageTitle, onAbout.missingAlt], [[], ['Hero banner'], 1]); + + assert.strictEqual(find(report, '/images/missing-trail.jpg').missingAlt, false); }); test('reads sitemap indexes and gzipped sitemaps, and flags orphan pages', () => { diff --git a/test/fixtures/site.js b/test/fixtures/site.js index 0717d2b..2701606 100644 --- a/test/fixtures/site.js +++ b/test/fixtures/site.js @@ -4,8 +4,8 @@ const http = require('http'); const zlib = require('zlib'); // Fake "Acme Outdoor" site with every kind of problem, plus a fake external site. -function layout(title, body) { - return `<!doctype html><html><head><title>${title} | Acme Outdoor` + +function layout(title, body, head = '') { + return `${title} | Acme Outdoor${head}` + '' + '' + `
${body}
` + @@ -14,8 +14,8 @@ function layout(title, body) { } const PAGES = { - '/': layout('Home', '

Gear for every trail

Tents Backpacks Summer sale Help centre Hero'), - '/about-us': layout('About us', '

Founded in 2009.

Meet the team Our partners Careers'), + '/': layout('Home', '

Gear for every trail

Tents Backpacks Summer sale Help centre Hero', ''), + '/about-us': layout('About us', '

About

Our story

Founded in 2009.

Meet the team Our partners Careers'), '/blog/': layout('Blog', 'Best hiking trails Packing list Old news'), '/blog/best-hiking-trails': layout('Best hiking trails', '

Our picks.

Get a tent Trail guide Source article Trail'), '/blog/packing-list': layout('Packing list', 'Backpacks Stoves Checklist PDF'),