複数ページをまとめてHTML Validationチェックする

## なぜHTML Validationを気にするべきか

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/426622/4541056b-31ed-f395-0138-047462d65378.png)


なぜHTML Validationを気にするべきなのでしょうか。
多くのプログラム言語と異なり、HTMLは曖昧な表記でもWebブラウザが計らってくれて上手に表示してくれる技術です。

主要ブラウザで正しく動作しているのであれば、文法上の細かな話は気にするべきではないと考えるかもしれません。

しかし、一方でHTMLの記述間違いがレイアウト崩れにつながったり、動作不良を起こすこともあります。

“HTMLの文法を守る”というルールを課している場合は、機械的に早期に問題を検出し、少ないコストで問題回避を実現できます。
“HTMLの文法を守る”というルールを課していない場合は、深刻な問題が大量のエラーに埋もれてしまい問題の検出を難しくします。
これがHTML Validationを気にするべき理由です。

## まとめてやりたいですね

[Markup Validation Service](https://validator.w3.org/)に1ページずつ指定するのは大変です。
制作中のWebサイトがあれば、まとめて検証したいところですね。

いくつか方法がありますが、npmで配布されている[html-validate](https://www.npmjs.com/package/html-validate)が便利です。
例えば、 `./dist/` 配下のHTMLすべて検証する場合は下記のように実行するだけです。
（※グローバルインストールしている場合 `npx` は不要）

```
npx html-validate dist
```

## sitemap.xmlをもとに巡回し検証

Astroなどを用いた静的HTMLであれば、前述のように生成後のHTMLファイルが格納されたディレクトリに対して実行すれば良いですが、サーバーサイドのプログラムが動作している場合もあるでしょう。

`sitemap.xml`を出力しているサイトが対象であれば、`html-validate`をNode.jsのプログラムから呼び出して、`sitemap.xml`を解析して得たURLを巡回するように実装すると良いです。

```javascript
import http from 'http';
import { parseString } from 'xml2js';
import { HtmlValidate } from 'html-validate';

const validate = new HtmlValidate({
    extends: ["html-validate:recommended"],
    rules: {
        "doctype-style": "off",
        "attr-quotes": "off",
        "no-trailing-whitespace": "off",
        "void-style": "off",
    },
});
const ignoreValidateSelector = [
    '.any-plugin-name .has-not-valid-element',
];

function fetchHTML(url) {
    return new Promise((resolve, reject) => {
        http.get(url, (res) => {
            let data = '';
            res.on('data', (chunk) => {
                data += chunk;
            });
            res.on('end', () => {
                resolve(data);
            });
        }).on('error', (err) => {
            reject(err);
        });
    });
}

function parseSitemap(sitemapUrl) {
    return new Promise((resolve, reject) => {
        http.get(sitemapUrl, (res) => {
            let data = '';
            res.on('data', (chunk) => {
                data += chunk;
            });
            res.on('end', () => {
                parseString(data, (err, result) => {
                    if (err) {
                        reject(err);
                        return;
                    }
                    const urls = [];
                    const urlsetUrls = getUrlsetUrls(result);
                    urls.push(...urlsetUrls);
                    const sitemapindexUrls = getSitemapindexUrls(result);
                    if (sitemapindexUrls.length > 0) {
                        const promises = sitemapindexUrls.map(url => parseSitemap(url));
                        Promise.all(promises)
                            .then(results => {
                                results.forEach(result => {
                                    urls.push(...result);
                                });
                                resolve(urls);
                            })
                            .catch(reject);
                    } else {
                        resolve(urls);
                    }
                });
            });
        }).on('error', (err) => {
            reject(err);
        });
    });
}

function getUrlsetUrls(result) {
    const urls = [];
    const urlset = result.urlset;
    if (urlset && urlset.url) {
        for (const url of urlset.url) {
            if (url.loc && url.loc.length > 0) {
                urls.push(url.loc[0]);
            }
        }
    }
    return urls;
}

function getSitemapindexUrls(result) {
    const urls = [];
    const sitemapindex = result.sitemapindex;
    if (sitemapindex && sitemapindex.sitemap) {
        for (const sitemap of sitemapindex.sitemap) {
            if (sitemap.loc && sitemap.loc.length > 0) {
                urls.push(sitemap.loc[0]);
            }
        }
    }
    return urls;
}

async function main() {
    const sitemapUrl = 'http://localhost/sitemap.xml';
    try {
        const urls = await parseSitemap(sitemapUrl);
        for (const url of urls) {
            try {
                // Validate HTML
                const html = await fetchHTML(url);
                const report = await validate.validateString(html);
                if (!report.valid) {
                  for (const result of report.results) {
                    console.log(`Validation report for ${url} :`);
                    for (const message of result.messages) {
                        if (!ignoreValidateSelector.includes(message.selector)) {
                            console.log(` Line ${message.line} :`, message.message);
                        }
                    }
                  }
                } else {
                    console.log(`Validation report for ${url} :`, 'No errors found');
                }
            } catch (error) {
                console.error(`Error fetching or validating HTML from ${url}:`, error);
            }
        }
    } catch (error) {
        console.error('Error downloading or parsing sitemap:', error);
    }
}

main();
```

### オプション設定や特定要素のエラー表示回避策

Webサイトを作るうえで、何らかの制限のためにどうしてもValidationエラーを解決できないケースがあります。
（他のシステムとの接続のため、導入しているプラグインのため等）

全体での設定についてはこのライブラリとして提供されている `new HtmlValidate` する際の `rules` オプションが便利です。

部分的な要素のエラー出力を抑制するには、上記のサンプルコードで実装しているように`ignoreValidateSelector`で、エラーが発生しても無視する要素のセレクタを定義すると良いでしょう。

例では、`.any-plugin-name .has-not-valid-element`の要素でのHTML Validationエラーをミュートしています。

[複数ページをまとめてLighthouseで検証する](https://qiita.com/koji-yamauchi/items/9fb537158c05bf4ac8f6)で記載した実装もこちらに組み込んで、まとめてGitHub Actionsに組み込んでも良いかもしれませんね。

---

株式会社add moreではこのように確認に手間がかかることを自動化したり、改善しながら制作や開発を行うエンジニアを募集しています。
ご興味のある方は[コーポレートサイト](https://www.add-more.co.jp/recruit/engineer/)または[Wantedly](https://www.wantedly.com/companies/add-more)からご応募ください。