Skip to content

Commit

Permalink
feat: 🎸 SEO check alpha
Browse files Browse the repository at this point in the history
  • Loading branch information
Nick Reese committed May 17, 2021
1 parent 3679fb6 commit 354cb8d
Show file tree
Hide file tree
Showing 7 changed files with 961 additions and 0 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ In this repo you'll find [Elder.js](https://elderguide.com/tech/elderjs/) plugin
- [Sitemap](https://github.com/Elderjs/plugins/tree/master/packages/sitemap) Automatically generate the latest sitemap for your Elder.js website on build.
- [Browser Reload](https://github.com/Elderjs/plugins/tree/master/packages/browser-reload) Reload the browser when your Elder.js server restarts.
- [References](https://github.com/Elderjs/plugins/tree/master/packages/references) Easily add wikipedia style references to your content.
- [SEO-Check](https://github.com/Elderjs/plugins/tree/master/packages/seo-check) **Alpha** easily check the HTML generated by Elder.js for common SEO issues.

## Other Plugins:

Expand Down
11 changes: 11 additions & 0 deletions packages/seo-check/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# `seo-check`

> TODO: description
## Usage

```
const seoCheck = require('seo-check');
// TODO: DEMONSTRATE API
```
178 changes: 178 additions & 0 deletions packages/seo-check/Tester.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
const cheerio = require('cheerio');

const $attributes = ($, search) => {
const arr = [];
$(search).each(function () {
const namespace = $(this)[0].namespace;
if (!namespace || namespace.includes('html')) {
const out = {
tag: $(this)[0].name,
innerHTML: $(this).html(),
innerText: $(this).text(),
};

if ($(this)[0].attribs) {
Object.entries($(this)[0].attribs).forEach((attr) => {
out[attr[0].toLowerCase()] = attr[1];
});
}

arr.push(out);
}
});
return arr;
};

const emptyRule = {
name: '',
description: '',
success: false,
errors: [],
warnings: [],
info: [],
};

const Tester = function (rules, siteWide = false) {
this.internalLinks = new Set();
this.pagesSeen = new Set();

this.currentUrl = '';

this.titleTags = new Map();
this.metaDescriptions = new Map();

this.currentRule = JSON.parse(JSON.stringify(emptyRule));

this.results = [];

const logMetaDescription = (meta) => {
if (this.metaDescriptions.has(meta)) {
} else {
this.metaDescriptions.set(meta, this.currentUrl);
}
};

const logTitleTag = (title) => {
if (this.titleTags.has(title)) {
} else {
this.titleTags.set(title, this.currentUrl);
}
};

const noEmptyRule = () => {
if (!this.currentRule.name || this.currentRule.name.length === 0) throw Error('No current test name');
if (!this.currentRule.description || this.currentRule.description.length === 0)
throw Error('No current test description');
};

const runTest = (defaultPriority = 50, arrName) => {
return (t, ...params) => {
let test = t;
let priority = defaultPriority;

// allows overwriting of priority
if (typeof test !== 'function') {
priority = t;
test = params.splice(0, 1)[0];
}

noEmptyRule();
this.count += 1;
try {
return test(...params);
} catch (e) {
this.currentRule[arrName].push({ message: e.message, priority });
return e;
}
};
};

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

const startRule = ({ validator, test, testData, ...payload }) => {
if (this.currentRule.errors.length > 0)
throw Error(
"Starting a new rule when there are errors that haven't been added to results. Did you run 'finishRule'? ",
);
if (this.currentRule.warnings.length > 0)
throw Error(
"Starting a new rule when there are warnings that haven't been added to results. Did you run 'finishRule'? ",
);
this.currentRule = Object.assign(this.currentRule, payload);
};
const finishRule = () => {
if (this.currentRule.errors.length === 0 && this.currentRule.warnings.length === 0) this.currentRule.success = true;
this.results.push(this.currentRule);
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);
}
const metaDescription = result.meta.find((m) => m.name && m.name.toLowerCase() === 'description');
if (metaDescription) {
logMetaDescription(metaDescription.content);
}

result.aTags.filter((a) => !a.href.includes('http')).forEach((a) => this.internalLinks.add(a.href));
}

for (let i = 0; i < rules.length; i++) {
const rule = rules[i];
startRule(rule);
await rule.validator({ result, response: { url } }, tester);
finishRule();
}

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 = [];
};
};

// 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.
32 changes: 32 additions & 0 deletions packages/seo-check/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
const Tester = require('./Tester');
const rules = require('./rules');

const notProd = process.env.NODE_ENV !== 'production' && process.env.NODE_ENV !== 'PRODUCTION';
const plugin = {
name: 'elderjs-plugin-seo-check',
description: 'Checks Elder.js generated HTML for common SEO issues.',
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);

return plugin;
},
config: {
display: ['errors', 'warnings'],
},
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);
}
},
},
],
};

module.exports = plugin;
131 changes: 131 additions & 0 deletions packages/seo-check/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading

0 comments on commit 354cb8d

Please sign in to comment.