Skip to content

Commit

Permalink
feat: 🎸 wire up sitewide auditing
Browse files Browse the repository at this point in the history
  • Loading branch information
Nick Reese committed May 18, 2021
1 parent d9b9d60 commit d850c22
Show file tree
Hide file tree
Showing 6 changed files with 212 additions and 232 deletions.
32 changes: 28 additions & 4 deletions packages/seo-check/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,21 @@

Checks the generated HTML for common SEO issues along with tips.

**ALPHA**
Works in single page mode and site wide mode.

Pro users can easily use this plugin to fire off an email to the marketing/content team any time an SEO issue is encountered.

## Working Checks

### Sitewide

These are only checked when Elder.js runs in build mode.

- [x] check for orphaned pages (no incoming internal links)
- [x] check for broken internal links.
- [x] check for duplicate title tags
- [x] check for duplicate meta descriptions

### Canonical

- [x] canonical tag exists
Expand Down Expand Up @@ -33,14 +44,14 @@ Checks the generated HTML for common SEO issues along with tips.
- [x] Meta description is less than than 120 chars
- [x] Meta description is longer than 300 chars (sometimes things
go REALLY wrong and this helps catch it.)
- [x] Meta description includes ~20% of the keywords of the title
tag. (useful in my experience.)
- [x] Meta description includes at least one the keywords of the title
tag.

### HTags

- [x] h1 Exists on page
- [x] only a single h1 per page.
- [x] h1 has 10% of the words in the title tag
- [x] h1 has at least one word from your title tag
- [x] h1 is less than 70 chars
- [x] h1 is more than than 10 chars
- [x] H2 or H3 don't exist if an H1 is missing.
Expand Down Expand Up @@ -68,6 +79,8 @@ Checks the generated HTML for common SEO issues along with tips.
- [x] Internal links have trailing slash
- [x] Internal links are not `nofollow`
- [x] Notifies if there are more than 50 outbound links on the page.
- [x] check for trailing `index.html`
- [x] internal fully formed links include 'https'

### Misc

Expand All @@ -87,6 +100,17 @@ Once installed, open your `elder.config.js` and configure the plugin by adding `
```javascript
plugins: {
'@elderjs/plugin-seo-check': {
display: ['errors', 'warnings'], // what level of reporting would you like.
handleSiteResults: async (results) => { // default.
// 'results' represents all of the issues found for the site wide build.
// power users can use this async function to post the issues to an endpoint or send an email
// so that the content or marketing team can address the issues.
if (Object.keys(results).length > 0) {
console.log(results);
} else {
console.log(`No SEO issues detected.`);
}
},
},

}
Expand Down
177 changes: 113 additions & 64 deletions packages/seo-check/Tester.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ const emptyRule = {
info: [],
};

const Tester = function (rules, siteWide = false) {
this.internalLinks = new Set();
const Tester = function (rules, display, siteWide) {
this.internalLinks = []; //[[link, linkedFrom]]
this.pagesSeen = new Set();

this.currentUrl = '';
Expand All @@ -44,16 +44,22 @@ const Tester = function (rules, siteWide = false) {
this.currentRule = JSON.parse(JSON.stringify(emptyRule));

this.results = [];
this.siteResults = {
duplicateTitles: [],
duplicateMetaDescriptions: [],
};

const logMetaDescription = (meta) => {
if (this.metaDescriptions.has(meta)) {
this.siteResults.duplicateMetaDescriptions.push([this.metaDescriptions.get(meta), this.currentUrl]);
} else {
this.metaDescriptions.set(meta, this.currentUrl);
}
};

const logTitleTag = (title) => {
if (this.titleTags.has(title)) {
this.siteResults.duplicateTitles.push([this.titleTags.get(title), this.currentUrl]);
} else {
this.titleTags.set(title, this.currentUrl);
}
Expand Down Expand Up @@ -87,11 +93,6 @@ const Tester = function (rules, siteWide = false) {
};
};

const tester = {
test: runTest(70, 'errors'),
lint: runTest(40, 'warnings'),
};

const startRule = ({ validator, test, testData, ...payload }) => {
if (this.currentRule.errors.length > 0)
throw Error(
Expand All @@ -109,70 +110,118 @@ const Tester = function (rules, siteWide = false) {
this.currentRule = JSON.parse(JSON.stringify(emptyRule));
};

return async (html, url) => {
this.currentUrl = url;
this.pagesSeen.add(url);

const $ = cheerio.load(html);

const result = {
html: $attributes($, 'html'),
title: $attributes($, 'title'),
meta: $attributes($, 'head meta'),
ldjson: $attributes($, 'script[type="application/ld+json"]'),
h1s: $attributes($, 'h1'),
h2s: $attributes($, 'h2'),
h3s: $attributes($, 'h3'),
h4s: $attributes($, 'h4'),
h5s: $attributes($, 'h5'),
h6s: $attributes($, 'h6'),
canonical: $attributes($, '[rel="canonical"]'),
imgs: $attributes($, 'img'),
aTags: $attributes($, 'a'),
linkTags: $attributes($, 'link'),
ps: $attributes($, 'p'),
};

if (siteWide) {
if (result.title[0] && result.title[0].innerText) {
logTitleTag(result.title[0].innerText);
return {
test: async (html, url) => {
try {
this.currentUrl = url;
this.pagesSeen.add(url);

const $ = cheerio.load(html);

const result = {
html: $attributes($, 'html'),
title: $attributes($, 'title'),
meta: $attributes($, 'head meta'),
ldjson: $attributes($, 'script[type="application/ld+json"]'),
h1s: $attributes($, 'h1'),
h2s: $attributes($, 'h2'),
h3s: $attributes($, 'h3'),
h4s: $attributes($, 'h4'),
h5s: $attributes($, 'h5'),
h6s: $attributes($, 'h6'),
canonical: $attributes($, '[rel="canonical"]'),
imgs: $attributes($, 'img'),
aTags: $attributes($, 'a'),
linkTags: $attributes($, 'link'),
ps: $attributes($, 'p'),
};

if (siteWide) {
if (result.title[0] && result.title[0].innerText) {
logTitleTag(result.title[0].innerText);
}
const metaDescription = result.meta.find((m) => m.name && m.name.toLowerCase() === 'description');
if (metaDescription) {
logMetaDescription(metaDescription.content);
}
result.aTags
.filter((a) => !!a.href)
.filter((a) => !a.href.includes('http'))
.filter((a) => {
if (this.currentUrl !== '/') {
return !a.href.endsWith(this.currentUrl);
}
return true;
})
.filter((a) => a.href !== this.currentUrl)
.map((a) => a.href)
.forEach((a) => this.internalLinks.push([a, this.currentUrl]));
}

for (let i = 0; i < rules.length; i++) {
const rule = rules[i];
startRule(rule);
await rule.validator(
{ result, response: { url } },
{
test: runTest(70, 'errors'),
lint: runTest(40, 'warnings'),
},
);
finishRule();
}

const validDisplay = ['warnings', 'errors'];
const out = display
.filter((d) => validDisplay.includes(d))
.reduce((out, key) => {
return [
...out,
...this.results
.filter((r) => !r.success)
.sort((a, b) => a.priority > b.priority)
.reduce((o, ruleResult) => {
return [...o, ...ruleResult[key].map((r) => ({ ...r, level: key }))];
}, []),
];
}, []);

if (siteWide) {
this.siteResults[url] = out;
} else {
if (out.length > 0) {
// eslint-disable-next-line node/no-unsupported-features/node-builtins
console.table(out);
}
}

this.results = [];
} catch (e) {
console.error(e);
}
const metaDescription = result.meta.find((m) => m.name && m.name.toLowerCase() === 'description');
if (metaDescription) {
logMetaDescription(metaDescription.content);
},
siteResults: async () => {
this.siteResults.orphanPages = [];
for (const page of this.pagesSeen.values()) {
if (!this.internalLinks.find((il) => il[0] === page)) this.siteResults.orphanPages.push(page);
}

result.aTags.filter((a) => !a.href.includes('http')).forEach((a) => this.internalLinks.add(a.href));
}
this.siteResults.brokenInternalLinks = [];
for (const [link, linker] of this.internalLinks) {
if (!this.pagesSeen.has(link)) this.siteResults.brokenInternalLinks.push({ link, linker });
}

for (let i = 0; i < rules.length; i++) {
const rule = rules[i];
startRule(rule);
await rule.validator({ result, response: { url } }, tester);
finishRule();
}
const results = Object.keys(this.siteResults).reduce((out, key) => {
if (Array.isArray(this.siteResults[key]) && this.siteResults[key].length > 0) {
out[key] = this.siteResults[key];
}
return out;
}, {});

const out = ['errors', 'warnings'].reduce((out, key) => {
return [
...out,
...this.results
.filter((r) => !r.success)
.sort((a, b) => a.priority > b.priority)
.reduce((o, ruleResult) => {
return [...o, ...ruleResult[key].map((r) => ({ ...r, level: key }))];
}, []),
];
}, []);

console.table(out);

this.results = [];
return results;
},
};
};

// eslint-disable-next-line jest/no-export
module.exports = Tester;

// accept rules one time.
// offer a function that tests all of the rules for a url.
// if in build mode test site wide rules.
50 changes: 44 additions & 6 deletions packages/seo-check/index.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
const glob = require('tiny-glob');
const path = require('path');
const fs = require('fs-extra');

const Tester = require('./Tester');
const rules = require('./rules');

Expand All @@ -8,21 +12,55 @@ const plugin = {
init: (plugin) => {
// used to store the data in the plugin's closure so it is persisted between loads

plugin.test = new Tester(rules, plugin.config.display, plugin.settings.build);
plugin.tester = new Tester(rules, plugin.config.display, plugin.settings.context === 'build');

return plugin;
},
config: {
display: ['errors', 'warnings'],
display: ['errors', 'warnings'], // what level of reporting would you like.
handleSiteResults: async (results) => {
// 'results' represents all of the issues found for the site wide build.
// power users can use this async function to post the issues to an endpoint or send an email
// so that the content or marketing team can address the issues.
if (Object.keys(results).length > 0) {
console.log(results);
} else {
console.log(`No SEO issues detected.`);
}
},
},
hooks: [
{
hook: 'html',
name: 'evaluateHtml',
description: 'Lints the elder.js response html',
run: async ({ request, plugin, htmlString }) => {
if (notProd) {
await plugin.test(htmlString, request.permalink);
description: 'Check the elder.js response html for common SEO issues.',
run: async ({ request, plugin, htmlString, settings }) => {
if (notProd && settings.context !== 'build') {
await plugin.tester.test(htmlString, request.permalink);
}
},
},
{
hook: 'buildComplete',
name: 'siteWideSeoCheck',
description: 'test',
run: async ({ settings, plugin, allRequests }) => {
if (settings.context === 'build') {
const files = await glob(`${settings.distDir}/**/*.html`);
const publicFolder = path.relative(settings.rootDir, settings.distDir);

for (let i = 0; i < files.length; i++) {
const file = files[i];

const html = fs.readFileSync(path.resolve(file), { encoding: 'utf-8' });

const relPermalink = file.replace('index.html', '').replace(publicFolder, '');
await plugin.tester.test(html, relPermalink);
}

const results = await plugin.tester.siteResults();

plugin.config.handleSiteResults(results);
}
},
},
Expand Down
Loading

0 comments on commit d850c22

Please sign in to comment.