├── test ├── fixtures │ ├── seolint.config.js │ ├── seolint.config.cjs │ └── seolint.config.mjs ├── index.test.ts └── config.test.ts ├── .gitignore ├── previous ├── index.ts ├── example.config.js ├── bin.ts ├── Tester.ts └── rules.ts ├── .editorconfig ├── src ├── utils │ ├── fs.ts │ └── http.ts ├── plugins │ ├── image.ts │ ├── viewport.ts │ ├── canonical.ts │ ├── title.ts │ ├── description.ts │ └── link.ts ├── config.ts └── index.ts ├── readme.md ├── tsconfig.json ├── .github └── workflows │ └── ci.yml ├── package.json ├── license ├── rollup.config.js ├── index.d.ts └── bin.js /test/fixtures/seolint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // 3 | } 4 | -------------------------------------------------------------------------------- /test/fixtures/seolint.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | *-lock.* 4 | *.lock 5 | *.log 6 | 7 | /index.js 8 | /index.mjs 9 | -------------------------------------------------------------------------------- /previous/index.ts: -------------------------------------------------------------------------------- 1 | export { Tester, defaultPreferences } from './Tester'; 2 | export { rules } from './rules'; 3 | -------------------------------------------------------------------------------- /test/fixtures/seolint.config.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | host: 'https://hello.com', 3 | inputs: [ 4 | 'https://hello.com/world/' 5 | ], 6 | rules: { 7 | 'canonical.href.match': false, 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_size = 2 6 | indent_style = tab 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.{json,yml,md}] 13 | indent_style = space 14 | -------------------------------------------------------------------------------- /previous/example.config.js: -------------------------------------------------------------------------------- 1 | // const { defaultPreferences, rules } = require('@nickreese/seo-lint'); 2 | module.exports = { 3 | rules: [], 4 | preferences: [], 5 | writeLocation: `./report.json`, // if this is set it assumes you want the report written. 6 | display: ['errors', 'warnings'], 7 | }; 8 | -------------------------------------------------------------------------------- /src/utils/fs.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import { promisify } from 'util'; 3 | 4 | export const exists = /*#__PURE__*/ fs.existsSync; 5 | export const list = /*#__PURE__*/ promisify(fs.readdir); 6 | export const write = /*#__PURE__*/ promisify(fs.writeFile); 7 | export const read = /*#__PURE__*/ promisify(fs.readFile); 8 | export const lstat = /*#__PURE__*/ promisify(fs.lstat); 9 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # seolint 2 | 3 | > (WIP) A robust and configurable SEO linter 4 | 5 | A work in progress – check back soon~! 6 | 7 | > **Note:** Currently traveling and forgot to push changes to this repo :sweat_smile: 8 | 9 | ## Install 10 | 11 | ```sh 12 | $ npm install seolint@next 13 | ``` 14 | 15 | ## Credits 16 | 17 | Originally written by [@nickreese](https://github.com/nickreese) to audit [Elder Guide](https://elderguide.com/). 18 | 19 | ## License 20 | 21 | MIT © [Nick Reese](https://nicholasreese.com) · [Luke Edwards](https://lukeed.com) 22 | -------------------------------------------------------------------------------- /src/utils/http.ts: -------------------------------------------------------------------------------- 1 | import { request } from 'https'; 2 | import { globalAgent } from 'http'; 3 | 4 | // @see (modified) lukeed/httpie 5 | export function fetch(url: string): Promise { 6 | return new Promise((res, rej) => { 7 | let html = ''; 8 | let agent = /^http:\/\//.test(url) && globalAgent; 9 | let req = request(url, { agent }, r => { 10 | let type = r.headers['content-type'] || ''; 11 | 12 | if (!type.includes('text/html')) { 13 | return rej('Invalid "Content-Type" header'); 14 | } 15 | 16 | r.setEncoding('utf8'); 17 | r.on('data', d => { html += d }); 18 | r.on('end', () => res(html)); 19 | }); 20 | 21 | req.on('error', rej); 22 | req.end(); // send 23 | }); 24 | } 25 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "ts-node": { 3 | "transpileOnly": true, 4 | "compilerOptions": { 5 | "module": "commonjs" 6 | }, 7 | "include": [ 8 | "test/**/*" 9 | ] 10 | }, 11 | "compilerOptions": { 12 | "strict": true, 13 | "target": "es2019", 14 | "module": "esnext", 15 | "noImplicitAny": true, 16 | "moduleResolution": "node", 17 | "forceConsistentCasingInFileNames": true, 18 | "allowSyntheticDefaultImports": false, 19 | "resolveJsonModule": true, 20 | "esModuleInterop": false, 21 | "removeComments": true, 22 | "sourceMap": false, 23 | "allowJs": false, 24 | "noEmit": true, 25 | "baseUrl": ".", 26 | "paths": { 27 | "seolint": ["index.d.ts"] 28 | } 29 | }, 30 | "include": [ 31 | "src" 32 | ], 33 | "exclude": [ 34 | "node_modules" 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | paths-ignore: 6 | - 'example/**' 7 | - '*.md' 8 | branches: 9 | - '**' 10 | tags-ignore: 11 | - '**' 12 | pull_request: 13 | paths-ignore: 14 | - 'example/**' 15 | - '*.md' 16 | branches: 17 | - master 18 | 19 | jobs: 20 | test: 21 | name: Node.js v${{ matrix.nodejs }} 22 | runs-on: ubuntu-latest 23 | strategy: 24 | matrix: 25 | nodejs: [12, 14] 26 | steps: 27 | - uses: actions/checkout@v2 28 | - uses: actions/setup-node@v2 29 | with: 30 | node-version: ${{ matrix.nodejs }} 31 | 32 | - name: Install 33 | run: | 34 | npm install 35 | npm install -g c8 36 | 37 | - name: Test w/ Coverage 38 | run: c8 --include=src npm test 39 | 40 | # - name: Report 41 | # if: matrix.nodejs >= 14 42 | # run: | 43 | # c8 report --reporter=text-lcov > coverage.lcov 44 | # bash <(curl -s https://codecov.io/bash) 45 | # env: 46 | # CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 47 | -------------------------------------------------------------------------------- /src/plugins/image.ts: -------------------------------------------------------------------------------- 1 | import type { Plugin } from 'seolint'; 2 | 3 | interface Image { 4 | 'image.alt.exists': boolean; 5 | 'image.alt.content.html': boolean; 6 | 'image.alt.content.undefined': boolean; 7 | 'image.alt.content.null': boolean; 8 | } 9 | 10 | export const image: Plugin = function (context, document) { 11 | let images = document.querySelectorAll('img'); 12 | 13 | images.forEach(img => { 14 | let alt = img.getAttribute('alt') || ''; 15 | 16 | context.assert( 17 | 'image.alt.exists', 18 | alt.trim().length > 0, 19 | 'Must have an `alt` attribute' 20 | ); 21 | 22 | // TODO 23 | // let toHTML = context.load('image.alt.content.html'); 24 | // if (toHTML) assert.falsey(alt.inner, 'Must not have HTML content within `alt` value'); 25 | 26 | context.assert( 27 | 'image.alt.content.undefined', 28 | alt.indexOf('undefined') === -1, 29 | 'Must not include "undefined" in `alt` value' 30 | ); 31 | 32 | context.assert( 33 | 'image.alt.content.null', 34 | alt.indexOf('null') === -1, 35 | 'Must not include "null" in `alt` value' 36 | ); 37 | }); 38 | } 39 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "seolint", 3 | "version": "1.0.0-next.1", 4 | "repository": "lukeed/seolint", 5 | "description": "A robust and configurable SEO linter", 6 | "module": "index.mjs", 7 | "main": "index.js", 8 | "license": "MIT", 9 | "bin": "bin.js", 10 | "author": "Nick Reese (https://elderguide.com)", 11 | "contributors": [ 12 | "Luke Edwards (https://lukeed.com)" 13 | ], 14 | "scripts": { 15 | "build": "rollup -c", 16 | "test": "uvu -r tsm -i fixtures test", 17 | "types": "tsc --noEmit" 18 | }, 19 | "exports": { 20 | ".": { 21 | "import": "./index.mjs", 22 | "require": "./index.js" 23 | }, 24 | "./package.json": "./package.json" 25 | }, 26 | "files": [ 27 | "index.d.ts", 28 | "index.*", 29 | "bin.js" 30 | ], 31 | "engines": { 32 | "node": ">=12" 33 | }, 34 | "dependencies": { 35 | "kleur": "^4.1.4", 36 | "node-html-parser": "^5.2.0" 37 | }, 38 | "keywords": [], 39 | "devDependencies": { 40 | "@types/node": "14.14.10", 41 | "rollup": "2.60.0", 42 | "terser": "5.10.0", 43 | "tsm": "2.1.4", 44 | "typescript": "4.4.4", 45 | "uvu": "0.5.2" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Nick Reese 4 | Copyright (c) 2021 Luke Edwards (https://lukeed.com) 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /src/plugins/viewport.ts: -------------------------------------------------------------------------------- 1 | import type { Plugin } from 'seolint'; 2 | 3 | interface Viewport { 4 | 'viewport.exists': boolean; 5 | 'viewport.single': boolean; 6 | 'viewport.content.empty': boolean; 7 | 'viewport.content.device': boolean; 8 | 'viewport.content.scale': boolean; 9 | } 10 | 11 | export const viewport: Plugin = function (context, document) { 12 | let [elem, ...rest] = document.querySelectorAll('meta[name=viewport]'); 13 | 14 | context.assert( 15 | 'viewport.exists', 16 | elem != null, 17 | 'A "meta[name=viewport]" tag must exist' 18 | ); 19 | 20 | context.assert( 21 | 'viewport.single', 22 | rest.length === 0, 23 | 'Must have only one "viewport" meta tag' 24 | ); 25 | 26 | let content = elem.getAttribute('content') || ''; 27 | 28 | context.assert( 29 | 'viewport.content.empty', 30 | content.trim().length > 0, 31 | 'Must not have empty "content" value' 32 | ); 33 | 34 | context.assert( 35 | 'viewport.content.device', 36 | content.includes('width=device-width'), 37 | 'Must include "width=device-width" value', 38 | ); 39 | 40 | context.assert( 41 | 'viewport.content.scale', 42 | content.includes('initial-scale=1'), 43 | 'Must must include "initial-scale=1" value', 44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /src/plugins/canonical.ts: -------------------------------------------------------------------------------- 1 | import type { Plugin } from 'seolint'; 2 | 3 | interface Canonical { 4 | 'canonical.exists': boolean; 5 | 'canonical.single': boolean; 6 | 'canonical.href.exists': boolean; 7 | 'canonical.href.absolute': boolean; 8 | 'canonical.href.lowercase': boolean; 9 | 'canonical.href.https': boolean; 10 | 'canonical.href.match': boolean | { 11 | hostname: string; 12 | }; 13 | } 14 | 15 | export const canonical: Plugin = function (context, document) { 16 | let [elem, ...rest] = document.querySelectorAll('link[rel=canonical]'); 17 | 18 | context.assert('canonical.exists', elem != null, 'A canonical link must exist'); 19 | context.assert('canonical.single', rest.length === 0, 'Must have only one canonical link'); 20 | 21 | let target = elem.getAttribute('href') || ''; 22 | 23 | context.assert( 24 | 'canonical.href.exists', 25 | target.length > 0, 26 | 'Must not have empty `href` target' 27 | ); 28 | 29 | context.assert( 30 | 'canonical.href.absolute', 31 | /^https?:\/\//.test(target), 32 | 'Must include a URL protocol in target' 33 | ); 34 | 35 | context.assert( 36 | 'canonical.href.lowercase', 37 | !/[A-Z]/.test(target), 38 | 'Must not include uppercase character(s)' 39 | ); 40 | 41 | context.assert( 42 | 'canonical.href.https', 43 | /^https:\/\//.test(target), 44 | 'Must point to an "https://" address' 45 | ); 46 | 47 | let { host } = context.options; 48 | 49 | context.assert( 50 | 'canonical.href.match', 51 | o => target.includes(o.hostname || host || '\0'), 52 | 'Must include the provided `hostname` value' 53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /src/plugins/title.ts: -------------------------------------------------------------------------------- 1 | import type { Plugin } from 'seolint'; 2 | 3 | interface Title { 4 | 'title.exists': boolean; 5 | 'title.single': boolean; 6 | 'title.content.html': boolean; 7 | 'title.content.undefined': boolean; 8 | 'title.content.null': boolean; 9 | 'title.content.length': boolean | { 10 | min?: number; 11 | max?: number; 12 | }; 13 | } 14 | 15 | // TODO: stop words 16 | export const title: Plugin = function (context, document) { 17 | let [elem, ...rest] = document.querySelectorAll('head>title'); 18 | 19 | context.assert('title.exists', elem != null, 'A title tag must exist'); 20 | context.assert('title.single', rest.length === 0, 'Must have only one title tag'); 21 | 22 | let text = (elem as HTMLTitleElement).innerText; 23 | 24 | context.assert( 25 | 'title.content.html', 26 | text === elem.innerHTML, 27 | 'Must not include HTML content' 28 | ); 29 | 30 | context.assert( 31 | 'title.content.undefined', 32 | text.indexOf('undefined') === -1, 33 | 'Must not include "undefined" in `title` value' 34 | ); 35 | 36 | context.assert( 37 | 'title.content.null', 38 | text.indexOf('null') === -1, 39 | 'Must not include "null" in `title` value' 40 | ); 41 | 42 | let rule = context.load('title.content.length'); 43 | 44 | if (rule) { 45 | let length = text.length; 46 | 47 | let config = rule === true ? {} : rule; 48 | let { min, max } = { min: 10, max: 300, ...config }; 49 | 50 | if (length < min) context.report('title.content.length', `Must not have less than ${min} characters`); 51 | else if (length > max) context.report('title.content.length', `Must not have more than ${max} characters`); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const { minify } = require('terser'); 4 | const { transpileModule } = require('typescript'); 5 | const tsconfig = require('./tsconfig.json'); 6 | const pkg = require('./package.json'); 7 | 8 | const resolve = { 9 | name: 'resolve', 10 | resolveId(file, importer) { 11 | let tmp, { ext } = path.parse(file); 12 | if (ext) return file; 13 | 14 | let next = path.resolve(path.dirname(importer), file); 15 | if (fs.existsSync(next)) return next; 16 | 17 | if (fs.existsSync(tmp = next + '.ts')) return tmp; 18 | if (fs.existsSync(tmp = next + '.js')) return tmp; 19 | 20 | return null; 21 | } 22 | }; 23 | 24 | const terser = { 25 | name: 'terser', 26 | renderChunk(code, _chunk, opts) { 27 | return minify(code, { 28 | toplevel: true, 29 | sourceMap: !!opts.sourcemap, 30 | compress: true, 31 | }); 32 | } 33 | }; 34 | 35 | const typescript = { 36 | name: 'typescript', 37 | transform(code, file) { 38 | if (!/\.ts$/.test(file)) return code; 39 | // @ts-ignore 40 | let output = transpileModule(code, { ...tsconfig, fileName: file }); 41 | return { 42 | code: output.outputText.replace('$$VERSION$$', pkg.version), 43 | map: output.sourceMapText || null 44 | }; 45 | } 46 | }; 47 | 48 | function make(file, format) { 49 | return { 50 | file, format, 51 | freeze: false, 52 | esModule: false, 53 | interop: false, 54 | strict: false, 55 | }; 56 | } 57 | 58 | module.exports = { 59 | input: 'src/index.ts', 60 | output: [ 61 | make('index.mjs', 'esm'), 62 | make('index.js', 'cjs'), 63 | ], 64 | external: [ 65 | ...require('module').builtinModules, 66 | ...Object.keys(pkg.dependencies), 67 | ], 68 | plugins: [ 69 | resolve, 70 | typescript, 71 | terser, 72 | ] 73 | } 74 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | type Dict = Record<string, any>; 2 | type Promisable<T> = Promise<T> | T; 3 | 4 | export type Rules<T extends Dict = Dict> = { 5 | [K in keyof T]: boolean | T[K]; 6 | }; 7 | 8 | export interface Context<T extends Dict = Rules> { 9 | readonly options: Pick<Config, 'host'> & Record<string, any>; 10 | 11 | load<K extends keyof T>(title: K): T[K] | void; 12 | report<K extends keyof T>(title: K, message: string): void; 13 | 14 | assert<K extends keyof T>(title: K, check: boolean, message: string): void; 15 | assert<K extends keyof T>(title: K, check: (options: Exclude<T[K],boolean>) => boolean, message: string): void; 16 | 17 | // TODO?: async checks 18 | // assert<K extends keyof T>(title: K, check: (options: Exclude<T[K],boolean>) => Promise<boolean>, message: string): Promise<void>; 19 | // assert<K extends keyof T>(title: K, check: boolean | ((options: Exclude<T[K],boolean>) => Promisable<boolean>), message: string): Promisable<void>; 20 | } 21 | 22 | export type Plugin<R extends Rules = Rules> = (context: Context<R>, document: HTMLElement) => Promisable<void>; 23 | 24 | export interface Argv { 25 | cwd?: string; 26 | host?: string; 27 | input?: string[]; 28 | } 29 | 30 | export interface Config { 31 | host?: string; 32 | inputs?: string[]; 33 | presets?: Config[]; 34 | plugins?: Plugin[]; 35 | rules?: Rules; 36 | } 37 | 38 | export interface Message { 39 | message: string; 40 | line?: number; 41 | col?: number; 42 | } 43 | 44 | export type Messages = { 45 | [rule: string]: Message; 46 | } 47 | 48 | export type Report = { 49 | [input: string]: Messages; 50 | } 51 | 52 | export function lint(html: string, config?: Omit<Config, 'inputs'>): Promise<Messages|void>; 53 | export function url(href: string, config?: Omit<Config, 'inputs'>): Promise<Report>; 54 | export function fs(path: string, config?: Omit<Config, 'inputs'>): Promise<Report>; 55 | export function run(config?: Config, options?: Argv): Promise<Report>; 56 | export function config(options?: Argv): Promise<Config>; 57 | 58 | export declare class Assertion extends Error { 59 | rule: string; 60 | constructor(message: string, rule: string); 61 | } 62 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import { dirname, join, resolve } from 'path'; 2 | import { list } from './utils/fs'; 3 | 4 | // Default plugins 5 | import { title } from './plugins/title'; 6 | import { canonical } from './plugins/canonical'; 7 | import { description } from './plugins/description'; 8 | import { viewport } from './plugins/viewport'; 9 | import { image } from './plugins/image'; 10 | import { link } from './plugins/link'; 11 | 12 | import type { Config, Plugin } from 'seolint'; 13 | 14 | const priority = [ 15 | 'seolint.config.mjs', 16 | 'seolint.config.cjs', 17 | 'seolint.config.js', 18 | ]; 19 | 20 | /** 21 | * Traverse upwards until find a config file 22 | * @NOTE Does not leave `root` directory 23 | * @see (modified) lukeed/escalade 24 | */ 25 | export async function find(root: string, start: string): Promise<string|void> { 26 | let i=0, dir = resolve(root, start); 27 | let files = new Set(await list(dir)); 28 | 29 | for (; i < priority.length; i++) { 30 | if (files.has(priority[i])) { 31 | return join(dir, priority[i]); 32 | } 33 | } 34 | 35 | dir = dirname(dir); 36 | 37 | if (dir.startsWith(root)) { 38 | return find(root, dir); 39 | } 40 | } 41 | 42 | /** 43 | * Merge config objects 44 | * Overrides default values 45 | * Priority: rules > plugins 46 | * @NOTE Mutates `base` object! 47 | */ 48 | export function merge(base: Config, custom: Config) { 49 | (custom.presets || []).forEach(x => merge(base, x)); 50 | if (custom.plugins) base.plugins!.push(...custom.plugins); 51 | if (custom.rules) Object.assign(base.rules!, custom.rules); 52 | base.inputs = custom.inputs || base.inputs; 53 | base.host = custom.host || base.host; 54 | } 55 | 56 | export async function load(root: string): Promise<Config> { 57 | let output: Config = { 58 | host: '', 59 | inputs: [], 60 | plugins: [], 61 | rules: {}, 62 | }; 63 | 64 | let file = await find(root, '.'); 65 | 66 | if (file) { 67 | let config = await Function('x', 'return import("file:///"+x)')(file); 68 | merge(output, config.default || config); 69 | } 70 | 71 | // include default plugins 72 | (output.plugins as Plugin<any>[]).unshift( 73 | title, canonical, description, 74 | viewport, image, link, 75 | ); 76 | 77 | return output; 78 | } 79 | -------------------------------------------------------------------------------- /src/plugins/description.ts: -------------------------------------------------------------------------------- 1 | import type { Plugin } from 'seolint'; 2 | 3 | interface Description { 4 | 'description.exists': boolean; 5 | 'description.single': boolean; 6 | 'description.content.empty': boolean; 7 | 'description.content.html': boolean; 8 | 'description.content.title': boolean; 9 | 'description.content.undefined': boolean; 10 | 'description.content.null': boolean; 11 | 'description.content.length': boolean | { 12 | min?: number; 13 | max?: number; 14 | }; 15 | } 16 | 17 | export const description: Plugin<Description> = function (context, document) { 18 | let [elem, ...rest] = document.querySelectorAll('meta[name=description]'); 19 | 20 | context.assert('description.exists', elem != null, 'A "meta[name=description]" must exist'); 21 | context.assert('description.single', rest.length === 0, 'Must have only one description meta tag'); 22 | 23 | let content = (elem.getAttribute('content') || '').trim(); 24 | 25 | context.assert( 26 | 'description.content.empty', 27 | content.length > 0, 28 | 'Must not be empty' 29 | ); 30 | 31 | // TODO: html chars 32 | context.assert( 33 | 'description.content.html', 34 | !elem.innerHTML, 35 | 'Must not include HTML content' 36 | ); 37 | 38 | context.assert( 39 | 'description.content.undefined', 40 | content.indexOf('undefined') === -1, 41 | 'Must not include "undefined" in value' 42 | ); 43 | 44 | context.assert( 45 | 'description.content.null', 46 | content.indexOf('null') === -1, 47 | 'Must not include "null" in value' 48 | ); 49 | 50 | let rule = context.load('description.content.length'); 51 | 52 | if (rule) { 53 | let length = content.length; 54 | 55 | let config = rule === true ? {} : rule; 56 | let { min, max } = { min: 10, max: 300, ...config }; 57 | 58 | if (length < min) context.report('description.content.length', `Must not have less than ${min} characters`); 59 | else if (length > max) context.report('description.content.length', `Must not have more than ${max} characters`); 60 | } 61 | 62 | let title = document.querySelector('title'); 63 | 64 | if (title) context.assert( 65 | 'description.content.title', 66 | () => { 67 | let words1 = new Set(content.toLowerCase().split(/[^\w]+/g)); 68 | let words2 = new Set(title!.innerText.trim().toLowerCase().split(/[^\w]+/g)); 69 | for (let w of words2) if (words1.has(w)) return true; 70 | return false; 71 | }, 72 | 'Must include at least one word in the <title> tag.' 73 | ); 74 | } 75 | -------------------------------------------------------------------------------- /previous/bin.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import sade from 'sade'; 4 | import path from 'path'; 5 | import fs from 'fs'; 6 | 7 | import { Tester } from './Tester'; 8 | 9 | sade('seo-lint <dir>', true) 10 | .version('1.0.0') 11 | .describe('Check a directory of HTML files for common SEO issues.') 12 | .example('public -H example.com -c seo-lint.config.js -w report.json') 13 | .option('-H, --host', 'Set the expected host for internal links. (example.com)') 14 | .option('-c, --config', 'Set a custom config to specify rules.') 15 | .option('-w, --write', 'Location to write a report to a JSON file.') 16 | .action(async (dir, opts) => { 17 | try { 18 | let config, writeLocation; 19 | if (opts.config) { 20 | const configLocation = path.resolve(opts.config); 21 | if (fs.existsSync(configLocation)) { 22 | config = require(configLocation); 23 | } else { 24 | throw Error(`No config found at ${configLocation}`); 25 | } 26 | } 27 | 28 | console.log('latest'); 29 | 30 | if (opts.write) { 31 | writeLocation = path.resolve(opts.write); 32 | } else if (config && config.writeLocation) { 33 | writeLocation = path.resolve(config.writeLocation); 34 | } 35 | 36 | if (writeLocation) { 37 | const parsedWL = path.parse(writeLocation); 38 | if (parsedWL.ext !== '.json') { 39 | throw new Error('--write or writeLocation in config must write to a .json file.'); 40 | } 41 | if (!fs.existsSync(parsedWL.dir)) { 42 | fs.mkdirSync(parsedWL.dir, { recursive: true }); 43 | } 44 | } 45 | 46 | // Program handler 47 | const tester = Tester({ siteWide: true, host: opts.host ? opts.host : '', ...config }); 48 | 49 | const { meta, ...results } = await tester.folder(dir); 50 | 51 | if (Object.keys(results).length > 0) { 52 | console.log(results); 53 | if (writeLocation) { 54 | fs.writeFileSync(writeLocation, JSON.stringify({ success: false, meta, results }, null, 2), { 55 | encoding: 'utf-8', 56 | }); 57 | } 58 | } else { 59 | console.log(`No SEO issues detected.`); 60 | if (writeLocation) { 61 | fs.writeFileSync(writeLocation, JSON.stringify({ success: true, meta, results }, null, 2), { 62 | encoding: 'utf-8', 63 | }); 64 | } 65 | } 66 | } catch (e) { 67 | console.error(e); 68 | } 69 | }) 70 | .parse(process.argv); 71 | -------------------------------------------------------------------------------- /test/index.test.ts: -------------------------------------------------------------------------------- 1 | import { suite } from 'uvu'; 2 | import { resolve } from 'path'; 3 | import * as assert from 'uvu/assert'; 4 | import * as seolint from '../src/index'; 5 | 6 | const fixtures = resolve(__dirname, 'fixtures'); 7 | 8 | // --- 9 | 10 | const config = suite('config()'); 11 | 12 | config('should be a function', () => { 13 | assert.type(seolint.config, 'function'); 14 | }); 15 | 16 | config('should resolve with `Config` defaults', async () => { 17 | let output = await seolint.config(); 18 | 19 | // defaults 20 | assert.is(output.host, ''); 21 | assert.instance(output.inputs, Array); 22 | assert.is(output.inputs.length, 0); 23 | assert.instance(output.plugins, Array); 24 | assert.is(output.plugins.length > 0, true); 25 | assert.equal(output.rules, {}); 26 | }); 27 | 28 | config('should accept `input` option', async () => { 29 | let output = await seolint.config({ 30 | input: ['foo', 'bar'] 31 | }); 32 | 33 | assert.equal(output.inputs, ['foo', 'bar']); 34 | }); 35 | 36 | config('should accept `host` option :: valid', async () => { 37 | let output = await seolint.config({ 38 | host: 'https://foobar.com' 39 | }); 40 | 41 | assert.is(output.host, 'https://foobar.com'); 42 | }); 43 | 44 | config('should accept `host` option :: invalid', async () => { 45 | try { 46 | await seolint.config({ host: 'foobar.com' }); 47 | assert.unreachable('should have thrown'); 48 | } catch (err) { 49 | assert.instance(err, Error); 50 | assert.is(err.message, 'A `host` value must include "http://" or "https://" protocol'); 51 | } 52 | }); 53 | 54 | config('should accept custom `cwd` option', async () => { 55 | let output = await seolint.config({ cwd: fixtures }); 56 | 57 | assert.is(output.host, 'https://hello.com'); 58 | 59 | assert.equal(output.inputs, [ 60 | 'https://hello.com/world/' 61 | ]); 62 | 63 | assert.equal(output.rules, { 64 | 'canonical.href.match': false, 65 | }); 66 | }); 67 | 68 | config('should always prefer `input` and `host` over config file', async () => { 69 | let output = await seolint.config({ 70 | cwd: fixtures, 71 | host: 'https://x.com', 72 | input: ['public'], 73 | }); 74 | 75 | assert.is.not(output.host, 'https://hello.com'); 76 | assert.is(output.host, 'https://x.com'); 77 | 78 | assert.equal(output.inputs, ['public']); 79 | 80 | // still loaded config file 81 | assert.equal(output.rules, { 82 | 'canonical.href.match': false, 83 | }); 84 | }); 85 | 86 | config.run(); 87 | 88 | // --- 89 | 90 | const Assertion = suite('Assertion'); 91 | 92 | Assertion('should extend `Error` class', () => { 93 | let foo = new seolint.Assertion('foo', 'bar'); 94 | assert.instance(foo, seolint.Assertion); 95 | assert.instance(foo, Error); 96 | }); 97 | 98 | Assertion('should have `message` and `stack` properties', () => { 99 | let foo = new seolint.Assertion('foo', 'bar'); 100 | assert.is(foo.message, 'foo'); 101 | assert.ok(foo.stack); 102 | }); 103 | 104 | Assertion('should have custom `rule` property', () => { 105 | let foo = new seolint.Assertion('foo', 'bar'); 106 | assert.is(foo.rule, 'bar'); 107 | }); 108 | 109 | Assertion.run(); 110 | -------------------------------------------------------------------------------- /src/plugins/link.ts: -------------------------------------------------------------------------------- 1 | import type { Plugin } from 'seolint'; 2 | 3 | interface Link { 4 | 'link.href.exists': boolean; 5 | 'link.href.empty': boolean; 6 | 7 | 'link.internal.trailing': boolean; 8 | 'link.internal.lowercase': boolean; 9 | 'link.internal.nofollow': boolean; 10 | 'link.internal.pretty': boolean; 11 | 'link.internal.absolute': boolean; 12 | 'link.internal.https': boolean; 13 | 14 | 'link.external.limit': boolean | { 15 | max: number 16 | }; 17 | 18 | 'link.blank.noopener': boolean; 19 | 'link.blank.noreferrer': boolean; 20 | } 21 | 22 | export const link: Plugin<Link> = function (context, document) { 23 | let externals = 0; 24 | let seen: Set<string> = new Set; 25 | let links = document.querySelectorAll('a'); 26 | 27 | let { host } = context.options; 28 | if (!host) console.warn('! cannot run `link.internal` rules without a known `host` value'); 29 | 30 | links.forEach(link => { 31 | let href = link.getAttribute('href') || ''; 32 | 33 | context.assert( 34 | 'link.href.exists', 35 | link.hasAttribute('href'), 36 | 'Must have an `href` attribute' 37 | ); 38 | 39 | if (href === '#') return; 40 | if (/^(mailto|javascript)[:]/.test(href)) return; 41 | 42 | let index = href.indexOf('#'); 43 | if (index !== -1) { 44 | href = href.substring(0, index); 45 | } 46 | 47 | if (seen.has(href)) return; 48 | seen.add(href); 49 | 50 | context.assert( 51 | 'link.href.empty', 52 | href.trim().length > 0, 53 | 'Must not have empty `href` target' 54 | ); 55 | 56 | let isExternal = true; 57 | 58 | if (host) { 59 | let { hostname } = new URL(href, 'http://x.com'); 60 | isExternal = hostname !== 'x.com' && hostname !== host; 61 | } 62 | 63 | if (isExternal) { 64 | externals++; 65 | } else { 66 | context.assert( 67 | 'link.internal.nofollow', 68 | link.getAttribute('rel') !== 'nofollow', 69 | 'Internal links must not include `rel=nofollow`' 70 | ); 71 | 72 | context.assert( 73 | 'link.internal.lowercase', 74 | !/[A-Z]/.test(href), 75 | 'Internal links should not include uppercase' 76 | ); 77 | 78 | context.assert( 79 | 'link.internal.trailing', 80 | /(\S+\.\w+){1,}$/.test(href) || href.endsWith('/'), 81 | 'Internal links must end with a trailing slash' 82 | ); 83 | 84 | context.assert( 85 | 'link.internal.pretty', 86 | !/\.html?$/i.test(href), 87 | 'Internal links should not include ".html" suffix' 88 | ); 89 | 90 | context.assert( 91 | 'link.internal.absolute', 92 | /^([/]{1,2}|https?:\/\/)/.test(href), 93 | 'Must be an absolute URL format' 94 | ); 95 | 96 | context.assert( 97 | 'link.internal.https', 98 | href === '/' || /^https:\/\//.test(href) || /^\/[^/]+/.test(href), 99 | 'Must include "https://" prefix' 100 | ); 101 | 102 | if (link.getAttribute('target') === '_blank') { 103 | let rel = link.getAttribute('rel') || ''; 104 | 105 | context.assert( 106 | 'link.blank.noopener', 107 | rel.includes('noopener'), 108 | 'Must include "rel=noopener" when "target=_blank" in use' 109 | ); 110 | 111 | context.assert( 112 | 'link.blank.noreferrer', 113 | rel.includes('noreferrer'), 114 | 'Must include "rel=noreferrer" when "target=_blank" in use' 115 | ); 116 | } 117 | } 118 | }); 119 | 120 | if (externals) { 121 | context.assert( 122 | 'link.external.limit', 123 | o => externals <= (o.max || 50), 124 | 'Exceeded external links maximum' 125 | ); 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { join, resolve } from 'path'; 2 | import { parse } from 'node-html-parser'; 3 | import { lstat, list, read } from './utils/fs'; 4 | import { fetch } from './utils/http'; 5 | import { load } from './config'; 6 | 7 | import type { Argv, Context, Config, Dict } from 'seolint'; 8 | import type { Report, Messages } from 'seolint'; 9 | 10 | export class Assertion extends Error { 11 | rule: string; 12 | constructor(message: string, rule: string) { 13 | super(message); 14 | this.rule = rule; 15 | } 16 | } 17 | 18 | export async function config(options: Argv = {}): Promise<Config> { 19 | options.cwd = resolve(options.cwd || '.'); 20 | 21 | let value = await load(options.cwd) || {}; 22 | if (options.host) value.host = options.host; 23 | if (options.input) value.inputs = options.input; 24 | 25 | if (value.host && !/^https?:\/\//.test(value.host)) { 26 | throw new Error('A `host` value must include "http://" or "https://" protocol'); 27 | } 28 | 29 | return value; 30 | } 31 | 32 | export async function lint(html: string, config?: Omit<Config, 'inputs'>): Promise<Messages|void> { 33 | let { plugins=[], rules={}, ...rest } = config || {}; 34 | 35 | let document = parse(html); 36 | let output: Messages = {}; 37 | let invalid = false; 38 | 39 | let context: Context = { 40 | get options() { 41 | return rest; 42 | }, 43 | load(title) { 44 | let tmp = rules[title]; 45 | return tmp == null || tmp; 46 | }, 47 | report(title, message) { 48 | throw new Assertion(message, title); 49 | }, 50 | assert(title: string, check: boolean | ((o: Dict) => boolean), msg: string) { 51 | let tmp = context.load(title); 52 | if (tmp === true) tmp = {}; 53 | else if (!tmp) return; 54 | 55 | let bool = typeof check === 'function' 56 | ? check(tmp) 57 | : check; 58 | 59 | bool || context.report(title, msg); 60 | } 61 | }; 62 | 63 | for (let fn of plugins) { 64 | try { 65 | // @ts-ignore - HTMLElement 66 | await fn(context, document); 67 | } catch (err) { 68 | if (err instanceof Assertion) { 69 | output[err.rule] = { message: err.message }; 70 | invalid = true; 71 | } else { 72 | console.error('ERROR', err.stack); 73 | } 74 | } 75 | } 76 | 77 | if (invalid) return output; 78 | } 79 | 80 | export async function fs(path: string, config?: Omit<Config, 'inputs'>): Promise<Report> { 81 | let output: Report = {}; 82 | 83 | let stats = await lstat(path); 84 | 85 | if (stats.isFile()) { 86 | if (!/\.html?$/.test(path)) return output; 87 | let data = await read(path, 'utf8'); 88 | 89 | return lint(data, config).then(msgs => { 90 | if (msgs) output[path] = msgs; 91 | return output; 92 | }); 93 | } 94 | 95 | let arr = await list(path); 96 | 97 | await Promise.all( 98 | arr.map(x => { 99 | let nxt = join(path, x); 100 | return fs(nxt, config).then(rep => { 101 | Object.assign(output, rep); 102 | }); 103 | }) 104 | ); 105 | 106 | return output; 107 | } 108 | 109 | export async function url(path: string, config?: Omit<Config, 'inputs'>): Promise<Report> { 110 | let output: Report = {}; 111 | let html = await fetch(path); 112 | if (!config || !config.host) { 113 | let host = new URL(path).origin; 114 | config = { ...config, host }; 115 | } 116 | let msgs = await lint(html, config); 117 | if (msgs) output[path] = msgs; 118 | return output; 119 | } 120 | 121 | export async function run(config?: Config, options?: Argv): Promise<Report> { 122 | let isHTTP=0, HTTP=/^https?:\/\//; 123 | let cwd = resolve(options && options.cwd || '.'); 124 | let i=0, inputs = ([] as string[]).concat(config && config.inputs || []); 125 | if (inputs.length === i) throw new Error('Missing inputs to analyze'); 126 | 127 | for (; i < inputs.length; i++) { 128 | if (HTTP.test(inputs[i])) isHTTP++; 129 | else inputs[i] = join(cwd, inputs[i]); 130 | } 131 | 132 | if (isHTTP && isHTTP !== inputs.length) { 133 | throw new Error('Inputs cannot contain both file-system and URL targets'); 134 | } 135 | 136 | let output: Report = {}; 137 | let action = isHTTP ? url : fs; 138 | 139 | await Promise.all( 140 | inputs.map(input => { 141 | return action(input, config).then(report => { 142 | Object.assign(output, report); 143 | }).catch(err => { 144 | console.error('ERROR', input, err.stack || err); 145 | }); 146 | }) 147 | ); 148 | 149 | return output; 150 | } 151 | -------------------------------------------------------------------------------- /bin.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const argv = process.argv.slice(2); 3 | const isPIPE = !process.stdin.isTTY; 4 | 5 | let i=0, key=''; 6 | let input=[], flags={}; 7 | for (; i <= argv.length; i++) { 8 | if (i === argv.length) { 9 | if (key) flags[key] = true; 10 | } else if (argv[i].charCodeAt(0) === 45) { 11 | if (key) flags[key] = true; 12 | key = argv[i]; 13 | } else { 14 | if (key) flags[key] = argv[i]; 15 | else input.push(argv[i]); 16 | key = ''; 17 | } 18 | } 19 | 20 | let host = flags['--host'] || flags['-H']; 21 | let quiet = flags['--quiet'] || flags['-q']; 22 | let json = flags['--json'] || flags['-j']; 23 | 24 | function bail(message, code = 1) { 25 | process.exitCode = code; 26 | console.error(message); 27 | } 28 | 29 | let piped = ''; 30 | function onpipe(chunk) { 31 | piped += chunk; 32 | } 33 | 34 | async function init() { 35 | const seolint = require('.'); 36 | 37 | try { 38 | var options = { host, input }; 39 | var config = await seolint.config(options); 40 | } catch (err) { 41 | return bail(err.stack || err); 42 | } 43 | 44 | try { 45 | var report = piped.length === 0 46 | ? await seolint.run(config, options) 47 | : await seolint.lint(piped, config).then(m => { 48 | return m ? { stdin: m } : {}; 49 | }); 50 | } catch (err) { 51 | return bail(err.stack || err); 52 | } 53 | 54 | if (quiet) { 55 | let item, _; 56 | for (item in report) 57 | for (_ in report[item]) 58 | return process.exit(1); 59 | return process.exit(0); 60 | } 61 | 62 | if (json) { 63 | // TODO: remove "message" keys? 64 | console.log(JSON.stringify(report)); 65 | return process.exit(0); 66 | } 67 | 68 | const colors = require('kleur/colors'); 69 | if (flags['--no-color']) colors.$.enabled = false; 70 | 71 | const CWD = process.cwd(); 72 | const SYM = colors.red(' ✘ '); 73 | const FAIL = colors.bold(colors.bgRed(' FAIL ')); 74 | const FILE = x => colors.bold(' ' + colors.underline(colors.white(x))); 75 | 76 | let total=0, output=''; 77 | let rule, item, errors, wMsg=0; 78 | 79 | for (item in report) { 80 | errors = report[item]; 81 | 82 | // reset per entry 83 | output = '\n'; 84 | wMsg = 0; 85 | 86 | // get widths first 87 | for (rule in errors) { 88 | total++; 89 | // TODO: col:row 90 | wMsg = Math.max(wMsg, errors[rule].message.length); 91 | } 92 | 93 | if (total > 0) { 94 | if (item.startsWith(CWD)) { 95 | item = item.substring(CWD.length).replace(/^[\\/]+/, ''); 96 | } 97 | 98 | output += FAIL + FILE(item) + '\n'; 99 | 100 | for (rule in errors) { 101 | // output += ' ' + colors.dim(' 9:44') + SYM; 102 | output += ' ' + SYM + errors[rule].message.padEnd(wMsg, ' '); 103 | output += ' ' + colors.dim(rule) + '\n'; 104 | } 105 | process.stderr.write(output); 106 | } 107 | } 108 | 109 | if (total > 0) { 110 | output = '\n' + FAIL + ' Reported ' + colors.red(total) + ' error'; 111 | if (total !== 1) output += 's'; 112 | console.error(output + '!\n'); 113 | process.exitCode = 1; 114 | } else { 115 | output = '\n' + colors.bold(colors.green(' PASS ')); 116 | output += ' Looks great! 🎉\n' 117 | console.log(output); 118 | } 119 | } 120 | 121 | if (flags['--help'] || flags['-h']) { 122 | let msg = ''; 123 | msg += '\n Usage'; 124 | msg += '\n $ seolint [inputs] [options]\n'; 125 | msg += '\n Options'; 126 | msg += '\n -i, --input Accept HTML via stdin pipe'; 127 | msg += '\n -H, --host Specify the target hostname'; 128 | msg += '\n -j, --json Print JSON output to console'; 129 | msg += '\n -q, --quiet Disable terminal reporting'; 130 | msg += '\n -v, --version Displays current version'; 131 | msg += '\n -h, --help Displays this message\n'; 132 | msg += '\n Examples'; 133 | msg += '\n $ seolint public'; 134 | msg += '\n $ seolint public/index.html'; 135 | msg += '\n $ seolint dir1 dir2 dir3/file.html'; 136 | msg += '\n $ seolint https://example.com https://google.com'; 137 | msg += '\n $ curl -s https://example.com | seolint -i'; 138 | msg += '\n $ find public -type f -exec seolint {} +\n'; 139 | return console.log(msg); 140 | } 141 | 142 | if (flags['--version'] || flags['-v']) { 143 | return console.log('seolint, v0.0.0'); 144 | } 145 | 146 | if (host === true) { 147 | key = flags['-H'] ? '-H' : '--host'; 148 | return bail(`Missing '${key}' value`); 149 | } 150 | 151 | if (quiet && quiet !== true) input.unshift(quiet); 152 | if (json && json !== true) input.unshift(json); 153 | 154 | if (key = flags['--input'] || flags['-i']) { 155 | if (key !== true) input.unshift(key); 156 | if (isPIPE) return process.stdin.on('data', onpipe).on('end', init); 157 | return bail(`Must use '-i' or '--input' with stdin pipe`); 158 | } 159 | 160 | init(); 161 | -------------------------------------------------------------------------------- /test/config.test.ts: -------------------------------------------------------------------------------- 1 | import { suite } from 'uvu'; 2 | import { resolve } from 'path'; 3 | import * as assert from 'uvu/assert'; 4 | import * as config from '../src/config'; 5 | 6 | import type { Config } from '../index'; 7 | 8 | const fixtures = resolve(__dirname, 'fixtures'); 9 | 10 | // --- 11 | 12 | const find = suite('find()'); 13 | 14 | find('should be a function', () => { 15 | assert.type(config.find, 'function'); 16 | }); 17 | 18 | find('should return `void` if file not found', async () => { 19 | let output = await config.find(__dirname, __dirname); 20 | assert.is(output, undefined); 21 | }); 22 | 23 | find('should return `string` if file found', async () => { 24 | let output = await config.find(__dirname, fixtures); 25 | assert.is(output, resolve(fixtures, 'seolint.config.mjs')); 26 | }); 27 | 28 | find.run(); 29 | 30 | // --- 31 | 32 | const load = suite('load()'); 33 | 34 | load('should be a function', () => { 35 | assert.type(config.load, 'function'); 36 | }); 37 | 38 | load('should always return `Config` object', async () => { 39 | let output = await config.load(__dirname); 40 | assert.type(output, 'object'); 41 | 42 | // defaults 43 | assert.is(output.host, ''); 44 | assert.instance(output.inputs, Array); 45 | assert.is(output.inputs.length, 0); 46 | assert.instance(output.plugins, Array); 47 | assert.is(output.plugins.length > 0, true); 48 | assert.equal(output.rules, {}); 49 | }); 50 | 51 | load('should merge `Config` from loaded config file', async () => { 52 | let output = await config.load(fixtures); 53 | 54 | assert.is(output.host, 'https://hello.com'); 55 | assert.equal(output.inputs, [ 56 | 'https://hello.com/world/' 57 | ]); 58 | assert.equal(output.rules, { 59 | 'canonical.href.match': false, 60 | }); 61 | }); 62 | 63 | load.run(); 64 | 65 | // --- 66 | 67 | const merge = suite('merge()'); 68 | 69 | merge('should be a function', () => { 70 | assert.type(config.merge, 'function'); 71 | }); 72 | 73 | merge('should merge two `Config` objects together', () => { 74 | let input: Config = { 75 | host: '', 76 | inputs: [], 77 | presets: [], 78 | plugins: [], 79 | rules: {} 80 | }; 81 | 82 | let next: Config = { 83 | host: 'https://x.com', 84 | inputs: ['foobar'], 85 | rules: { 86 | 'a.b': 123, 87 | 'a.c': 456, 88 | } 89 | }; 90 | 91 | let before = JSON.stringify(input); 92 | let output = config.merge(input, next); 93 | let after = JSON.stringify(input); 94 | 95 | assert.is(output, undefined); 96 | assert.not.equal(input, next); 97 | assert.is.not(before, after); // mutated 98 | 99 | assert.equal(input, { 100 | host: 'https://x.com', 101 | inputs: ['foobar'], 102 | presets: [], 103 | plugins: [], 104 | rules: { 105 | 'a.b': 123, 106 | 'a.c': 456, 107 | } 108 | }); 109 | }); 110 | 111 | merge('should concatenate `plugins` arrays', () => { 112 | let input: Config = { 113 | plugins: [ 114 | // @ts-ignore 115 | 'plugin1', 'plugin2', 116 | ] 117 | }; 118 | 119 | let next: Config = { 120 | plugins: [ 121 | // @ts-ignore 122 | 'next1', 'next2' 123 | ] 124 | }; 125 | 126 | config.merge(input, next); 127 | assert.equal(input.plugins, ['plugin1', 'plugin2', 'next1', 'next2']); 128 | }); 129 | 130 | merge('should override `inputs` arrays', () => { 131 | let input: Config = { 132 | inputs: ['foo1', 'foo2'] 133 | }; 134 | 135 | let next: Config = { 136 | inputs: ['next1', 'next2'] 137 | }; 138 | 139 | config.merge(input, next); 140 | assert.equal(input.inputs, ['next1', 'next2']); 141 | }); 142 | 143 | merge('should merge `rules` objects', () => { 144 | let input: Config = { 145 | rules: { 146 | foo: 1, 147 | bar: 2 148 | } 149 | }; 150 | 151 | let next: Config = { 152 | rules: { 153 | bar: 123, 154 | baz: 456, 155 | } 156 | }; 157 | 158 | config.merge(input, next); 159 | assert.equal(input.rules, { 160 | foo: 1, 161 | bar: 123, 162 | baz: 456 163 | }); 164 | }); 165 | 166 | merge('should merge `presets` recursively', () => { 167 | let input: Config = { 168 | host: '', 169 | inputs: [], 170 | plugins: [], 171 | rules: {}, 172 | }; 173 | 174 | // preset #2 175 | let bar: Config = { 176 | host: 'https://bar.com', 177 | inputs: ['bar-dir'], 178 | plugins: [ 179 | // @ts-ignore 180 | 'bar1', 'bar2', 181 | ], 182 | rules: { 183 | 'bar.1': true, 184 | 'bar.2': true, 185 | } 186 | }; 187 | 188 | // preset #1 189 | let foo: Config = { 190 | host: 'https://foo.com', 191 | inputs: ['foo-dir'], 192 | plugins: [ 193 | // @ts-ignore 194 | 'foo1', 'foo2', 195 | ], 196 | presets: [ 197 | bar, 198 | ], 199 | rules: { 200 | 'foo.1': true, 201 | 'foo.2': true, 202 | } 203 | }; 204 | 205 | let custom: Config = { 206 | host: 'https://hello.com', 207 | inputs: ['public', 'examples'], 208 | plugins: [ 209 | // @ts-ignore 210 | 'custom1', 'custom2', 211 | ], 212 | presets: [ 213 | foo, 214 | ], 215 | rules: { 216 | 'foo.1': false, 217 | 'bar.2': false, 218 | 'hello': 123, 219 | } 220 | }; 221 | 222 | config.merge(input, custom); 223 | 224 | assert.equal(input, { 225 | host: 'https://hello.com', 226 | inputs: ['public', 'examples'], 227 | // @ts-ignore 228 | plugins: [ 229 | 'bar1', 'bar2', 'foo1', 'foo2', 230 | 'custom1', 'custom2' 231 | ], 232 | rules: { 233 | 'bar.1': true, 234 | 'bar.2': false, 235 | 'foo.1': false, 236 | 'foo.2': true, 237 | 'hello': 123, 238 | } 239 | }); 240 | }); 241 | 242 | merge.run(); 243 | -------------------------------------------------------------------------------- /previous/Tester.ts: -------------------------------------------------------------------------------- 1 | import cheerio from 'cheerio'; 2 | 3 | import { totalist } from 'totalist/sync'; 4 | import fs from 'fs'; 5 | import path from 'path'; 6 | import { rules as pkgRules } from './rules'; 7 | 8 | export const defaultPreferences = { 9 | internalLinksLowerCase: true, 10 | internalLinksTrailingSlash: true, 11 | }; 12 | 13 | const getHtmlFiles = (p): string[] => { 14 | const html = new Set(); 15 | totalist(p, (name: string, abs: string, stats) => { 16 | if (/\.html$/.test(name) && !name.includes('node_modules')) { 17 | html.add(abs); 18 | } 19 | }); 20 | return [...html] as string[]; 21 | }; 22 | 23 | const $attributes = ($, search) => { 24 | const arr = []; 25 | $(search).each(function () { 26 | const namespace = $(this)[0].namespace; 27 | if (!namespace || namespace.includes('html')) { 28 | const out = { 29 | tag: $(this)[0].name, 30 | innerHTML: $(this).html(), 31 | innerText: $(this).text(), 32 | }; 33 | 34 | if ($(this)[0].attribs) { 35 | Object.entries($(this)[0].attribs).forEach((attr) => { 36 | out[attr[0].toLowerCase()] = attr[1]; 37 | }); 38 | } 39 | 40 | arr.push(out); 41 | } 42 | }); 43 | return arr; 44 | }; 45 | 46 | type TMessage = { 47 | message: string; 48 | priority: number; 49 | }; 50 | 51 | const emptyRule = { 52 | name: '', 53 | description: '', 54 | success: false as boolean, 55 | errors: [] as TMessage[], 56 | warnings: [] as TMessage[], 57 | info: [] as TMessage[], 58 | }; 59 | 60 | type TRule = typeof emptyRule; 61 | 62 | type TPair = [string, string]; 63 | 64 | type TSiteResults = { 65 | [key: string]: any[]; 66 | }; 67 | 68 | type TLinkers = { 69 | [key: string]: string[]; 70 | }; 71 | 72 | export const Tester = function ({ 73 | rules = [], 74 | display = ['errors', 'warnings'], 75 | siteWide = false, 76 | host = '', 77 | preferences = {}, 78 | }) { 79 | preferences = { ...defaultPreferences, ...preferences }; 80 | this.currentRule = JSON.parse(JSON.stringify(emptyRule)); 81 | this.currentUrl = ''; 82 | 83 | const rulesToUse = rules.length > 0 ? rules : pkgRules; 84 | const internalLinks: Array<TPair> = []; //[[link, linkedFrom]] 85 | const pagesSeen: Set<string> = new Set(); 86 | const siteWideLinks = new Map(); 87 | 88 | const titleTags = new Map(); 89 | const metaDescriptions = new Map(); 90 | 91 | let results: TRule[] = []; 92 | const siteResults: TSiteResults = { 93 | duplicateTitles: [], 94 | duplicateMetaDescriptions: [], 95 | orphanPages: [], 96 | brokenInternalLinks: [], 97 | }; 98 | 99 | const logMetaDescription = (meta) => { 100 | if (metaDescriptions.has(meta)) { 101 | siteResults.duplicateMetaDescriptions.push([metaDescriptions.get(meta), this.currentUrl]); 102 | } else { 103 | metaDescriptions.set(meta, this.currentUrl); 104 | } 105 | }; 106 | 107 | const logTitleTag = (title) => { 108 | if (titleTags.has(title)) { 109 | siteResults.duplicateTitles.push([titleTags.get(title), this.currentUrl]); 110 | } else { 111 | titleTags.set(title, this.currentUrl); 112 | } 113 | }; 114 | 115 | const noEmptyRule = () => { 116 | if (!this.currentRule.name || this.currentRule.name.length === 0) throw Error('No current test name'); 117 | if (!this.currentRule.description || this.currentRule.description.length === 0) 118 | throw Error('No current test description'); 119 | }; 120 | 121 | const runTest = (defaultPriority = 50, arrName) => { 122 | return (t, ...params) => { 123 | let test = t; 124 | let priority = defaultPriority; 125 | 126 | // allows overwriting of priority 127 | if (typeof test !== 'function') { 128 | priority = t; 129 | test = params.splice(0, 1)[0]; 130 | } 131 | 132 | noEmptyRule(); 133 | this.count += 1; 134 | try { 135 | return test(...params); 136 | } catch (e) { 137 | this.currentRule[arrName].push({ message: e.message, priority }); 138 | return e; 139 | } 140 | }; 141 | }; 142 | 143 | const startRule = ({ validator, test, testData, ...payload }) => { 144 | if (this.currentRule.errors.length > 0) 145 | throw Error( 146 | "Starting a new rule when there are errors that haven't been added to results. Did you run 'finishRule'? ", 147 | ); 148 | if (this.currentRule.warnings.length > 0) 149 | throw Error( 150 | "Starting a new rule when there are warnings that haven't been added to results. Did you run 'finishRule'? ", 151 | ); 152 | this.currentRule = Object.assign(this.currentRule, payload); 153 | }; 154 | const finishRule = () => { 155 | if (this.currentRule.errors.length === 0 && this.currentRule.warnings.length === 0) this.currentRule.success = true; 156 | results.push(this.currentRule); 157 | this.currentRule = JSON.parse(JSON.stringify(emptyRule)); 158 | }; 159 | 160 | const reset = () => { 161 | results = []; 162 | }; 163 | 164 | const test = async (html: string, url: string) => { 165 | try { 166 | this.currentUrl = url; 167 | pagesSeen.add(url); 168 | 169 | const $ = cheerio.load(html); 170 | 171 | const result = { 172 | html: $attributes($, 'html'), 173 | title: $attributes($, 'title'), 174 | meta: $attributes($, 'head meta'), 175 | ldjson: $attributes($, 'script[type="application/ld+json"]'), 176 | h1s: $attributes($, 'h1'), 177 | h2s: $attributes($, 'h2'), 178 | h3s: $attributes($, 'h3'), 179 | h4s: $attributes($, 'h4'), 180 | h5s: $attributes($, 'h5'), 181 | h6s: $attributes($, 'h6'), 182 | canonical: $attributes($, '[rel="canonical"]'), 183 | imgs: $attributes($, 'img'), 184 | aTags: $attributes($, 'a'), 185 | linkTags: $attributes($, 'link'), 186 | ps: $attributes($, 'p'), 187 | }; 188 | 189 | if (siteWide) { 190 | siteWideLinks.set(url, result.aTags); 191 | if (result.title[0] && result.title[0].innerText) { 192 | logTitleTag(result.title[0].innerText); 193 | } 194 | const metaDescription = result.meta.find((m) => m.name && m.name.toLowerCase() === 'description'); 195 | if (metaDescription) { 196 | logMetaDescription(metaDescription.content); 197 | } 198 | result.aTags 199 | .filter((a) => !!a.href) 200 | .filter((a) => !a.href.includes('http')) 201 | .filter((a) => { 202 | if (this.currentUrl !== '/') { 203 | return !a.href.endsWith(this.currentUrl); 204 | } 205 | return true; 206 | }) 207 | .filter((a) => a.href !== this.currentUrl) 208 | .map((a) => a.href) 209 | .forEach((a) => internalLinks.push([a, this.currentUrl])); 210 | } 211 | 212 | for (let i = 0; i < rulesToUse.length; i++) { 213 | const rule = rulesToUse[i]; 214 | startRule(rule); 215 | await rule.validator( 216 | { result, response: { url, host }, preferences }, 217 | { 218 | test: runTest(70, 'errors'), 219 | lint: runTest(40, 'warnings'), 220 | }, 221 | ); 222 | finishRule(); 223 | } 224 | 225 | const validDisplay = ['warnings', 'errors']; 226 | 227 | const out = display 228 | .filter((d) => validDisplay.includes(d)) 229 | .reduce((out, key) => { 230 | return [ 231 | ...out, 232 | ...results 233 | .filter((r) => !r.success) 234 | .reduce((o, ruleResult) => { 235 | return [ 236 | ...o, 237 | ...ruleResult[key] 238 | .sort((a: TMessage, b: TMessage) => a.priority - b.priority) 239 | .map((r) => ({ ...r, level: key })), 240 | ]; 241 | }, [] as TMessage[]), 242 | ]; 243 | }, [] as TMessage[]); 244 | 245 | if (siteWide) { 246 | siteResults[url] = out; 247 | } else { 248 | return out; 249 | } 250 | 251 | results = []; 252 | } catch (e) { 253 | console.error(e); 254 | } 255 | }; 256 | 257 | return { 258 | test, 259 | reset, 260 | folder: async (folder) => { 261 | const parsedFolder = path.parse(path.resolve(folder)); 262 | const normalizedFolder = `${parsedFolder.dir}/${parsedFolder.base}`; 263 | 264 | const files = getHtmlFiles(`${normalizedFolder}`); 265 | 266 | for (let i = 0; i < files.length; i++) { 267 | const file = files[i]; 268 | const html = fs.readFileSync(path.resolve(file), { encoding: 'utf-8' }); 269 | 270 | const relPermalink = file.replace('index.html', '').replace(normalizedFolder, ''); 271 | // eslint-disable-next-line jest/expect-expect 272 | await test(html, relPermalink); 273 | } 274 | 275 | for (const page of pagesSeen.values()) { 276 | if (!internalLinks.find((il) => il[0] === page)) siteResults.orphanPages.push(page); 277 | } 278 | 279 | for (const [link, linker] of internalLinks) { 280 | if (!pagesSeen.has(link)) siteResults.brokenInternalLinks.push({ link, linker }); 281 | } 282 | 283 | const whatLinksWhere: TLinkers = {}; 284 | for (const [linker, links] of siteWideLinks.entries()) { 285 | for (let i = 0; i < links.length; i++) { 286 | const link = links[i]; 287 | if (!whatLinksWhere[link.href]) whatLinksWhere[link.href] = []; 288 | whatLinksWhere[link.href].push(linker); 289 | } 290 | } 291 | 292 | const outResults = Object.keys(siteResults).reduce( 293 | (out, key) => { 294 | if (Array.isArray(siteResults[key]) && siteResults[key].length > 0) { 295 | out[key] = siteResults[key]; 296 | } 297 | return out; 298 | }, 299 | { meta: { whatLinksWhere } }, 300 | ); 301 | 302 | return outResults; 303 | }, 304 | }; 305 | }; 306 | -------------------------------------------------------------------------------- /previous/rules.ts: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import { defaultPreferences } from './Tester'; 3 | 4 | const cleanString = (str) => 5 | str 6 | .toLowerCase() 7 | .replace('|', '') 8 | .replace('-', '') 9 | .replace('.', '') 10 | .replace(':', '') 11 | .replace('!', '') 12 | .replace('?', ''); 13 | 14 | export const rules = [ 15 | { 16 | name: 'Canonical Tag', 17 | description: `Validates that the canonical tag is well formed, that there isn't multiple, and that it matches the url crawled.`, 18 | testData: { 19 | preferences: defaultPreferences, 20 | response: { 21 | url: 'https://nicholasreese.com/', 22 | }, 23 | result: { 24 | canonical: [{ rel: 'canonical', href: 'https://nicholasreese.com/', innerText: '', innerHTML: '' }], 25 | }, 26 | }, 27 | validator: async (payload, tester) => { 28 | const canonicals = payload.result.canonical; 29 | tester.test( 30 | 100, 31 | assert.strictEqual, 32 | canonicals.length, 33 | 1, 34 | `There should be 1 and only 1 canonical tag, currently there are ${canonicals.length}`, 35 | ); 36 | if (canonicals[0]) { 37 | const { url, host } = payload.response; 38 | tester.test( 39 | 100, 40 | assert.ok, 41 | canonicals[0].href.includes('http') && canonicals[0].href.includes(host) && canonicals[0].href.includes(url), 42 | `Canonical should match absolute url and match the url that was crawled. host:${host} | crawled: ${url} | canonical: ${canonicals[0].href}`, 43 | ); 44 | } 45 | }, 46 | }, 47 | { 48 | name: 'Title tag', 49 | description: `Validate that the title tag exists, isn't too long, and isn't too short.`, 50 | testData: { 51 | preferences: defaultPreferences, 52 | result: { 53 | title: [ 54 | { 55 | innerText: 'Nick Reese - Actionable Business Advice for Entrepreneurs', 56 | innerHTML: 'Nick Reese - Actionable Business Advice for Entrepreneurs', 57 | }, 58 | ], 59 | }, 60 | }, 61 | validator: async (payload, tester) => { 62 | const titles = payload.result.title; 63 | tester.test( 64 | 100, 65 | assert.strictEqual, 66 | titles.length, 67 | 1, 68 | `There should only one and only 1 title tag, currently there are ${titles.length}`, 69 | ); 70 | 71 | if (titles.length !== 1) return; 72 | 73 | if (titles[0]) { 74 | tester.test( 75 | 90, 76 | assert.strictEqual, 77 | titles[0].innerText, 78 | titles[0].innerHTML, 79 | 'The title tag should not wrap other tags. (innerHTML and innerText should match)', 80 | ); 81 | tester.test(100, assert.notStrictEqual, titles[0].innerText.length, 0, 'Title tags should not be empty'); 82 | 83 | tester.test(100, assert.ok, !titles[0].innerText.includes('undefined'), `Title tag includes "undefined"`); 84 | tester.test(100, assert.ok, !titles[0].innerText.includes('null'), `Title tag includes "null"`); 85 | 86 | tester.lint( 87 | assert.ok, 88 | titles[0].innerText.length > 10, 89 | 'This title tag is shorter than the recommended minimum limit of 10.', 90 | ); 91 | tester.lint( 92 | assert.ok, 93 | titles[0].innerText.length < 70, 94 | 'This title tag is longer than the recommended limit of 70.', 95 | ); 96 | 97 | tester.test( 98 | assert.ok, 99 | titles[0].innerText.length < 200, 100 | `Something could be wrong this title tag is over 200 chars. : ${titles[0].innerText}`, 101 | ); 102 | 103 | const stopWords = ['a', 'and', 'but', 'so', 'on', 'or', 'the', 'was', 'with']; 104 | 105 | stopWords.forEach((sw) => { 106 | tester.lint( 107 | assert.ok, 108 | titles[0].innerText.toLowerCase().indexOf(` ${sw} `), 109 | `Title tag includes stopword ${sw}`, 110 | ); 111 | }); 112 | } 113 | }, 114 | }, 115 | { 116 | name: 'Meta description', 117 | description: `Validate that a meta description exists, isn't too long, isn't too short, and uses at least a few keywords from the title.`, 118 | testData: { 119 | preferences: defaultPreferences, 120 | result: { 121 | meta: [ 122 | { 123 | name: 'description', 124 | content: 'Nick Reese teaches you how effectively market your business both online and offline.', 125 | }, 126 | ], 127 | title: [ 128 | { 129 | innerText: 'Nick Reese - Actionable Business Advice for Entrepreneurs', 130 | innerHTML: 'Nick Reese - Actionable Business Advice for Entrepreneurs', 131 | }, 132 | ], 133 | }, 134 | }, 135 | validator: async (payload, tester) => { 136 | const metas = payload.result.meta.filter((m) => m.name && m.name.toLowerCase() === 'description'); 137 | 138 | tester.test( 139 | 90, 140 | assert.ok, 141 | metas.length === 1, 142 | `There should be 1 and only 1 meta description. Currently there are ${metas.length}`, 143 | ); 144 | 145 | if (metas[0]) { 146 | tester.test(90, assert.ok, metas[0] && metas[0].content, 'Meta description content="" should not be missing.'); 147 | tester.test(90, assert.notStrictEqual, metas[0].content.length, 0, 'Meta description should not be empty'); 148 | tester.test(100, assert.ok, !metas[0].content.includes('undefined'), `Meta description includes "undefined"`); 149 | tester.test(100, assert.ok, !metas[0].content.includes('null'), `Meta description includes "null"`); 150 | 151 | tester.lint( 152 | assert.ok, 153 | metas[0].content.length > 10, 154 | `This meta description is shorter than the recommended minimum limit of 10. (${metas[0].content})`, 155 | ); 156 | tester.lint( 157 | 30, 158 | assert.ok, 159 | metas[0].content.length < 120, 160 | `This meta description is longer than the recommended limit of 120. ${metas[0].content.length} (${metas[0].content})`, 161 | ); 162 | 163 | tester.test( 164 | assert.ok, 165 | metas[0].content.length < 300, 166 | 'Investigate this meta description. Something could be wrong as it is over 300 chars.', 167 | ); 168 | 169 | if (payload.result.title[0]) { 170 | const titleArr = cleanString(payload.result.title[0].innerText) 171 | .split(' ') 172 | .filter((i) => [':', '|', '-'].indexOf(i) === -1); 173 | 174 | const compareArr = cleanString(metas[0].content) 175 | .split(' ') 176 | .filter((i) => [':', '|', '-'].indexOf(i) === -1); 177 | 178 | const matches = titleArr.filter((t) => compareArr.indexOf(t) !== -1); 179 | 180 | tester.lint( 181 | 70, 182 | assert.ok, 183 | matches.length >= 1, 184 | 'Meta description should include at least 1 of the words in the title tag.', 185 | ); 186 | } 187 | } 188 | }, 189 | }, 190 | { 191 | name: 'HTags', 192 | description: `Validate that H tags are being used properly.`, 193 | testData: { 194 | preferences: defaultPreferences, 195 | result: { 196 | title: [ 197 | { 198 | innerText: 'Nick Reese - Actionable Business Advice for Entrepreneurs', 199 | innerHTML: 'Nick Reese - Actionable Business Advice for Entrepreneurs', 200 | }, 201 | ], 202 | h1s: [{ innerText: 'Entrepreneurs', innerHTML: 'Entrepreneurs' }], 203 | h2s: [{ innerText: 'Advice from Nick Reese', innerHTML: 'Advice from Nick Reese' }], 204 | h3s: [ 205 | { 206 | innerText: "WHAT'S ON THIS WEBSITE For Entrepreneurs?", 207 | innerHTML: "What's On This Website For Entrepreneurs?", 208 | }, 209 | ], 210 | h4s: [], 211 | h5s: [], 212 | h6s: [], 213 | }, 214 | }, 215 | validator: async (payload, tester) => { 216 | const { h1s, h2s, h3s, h4s, h5s, h6s, title, html } = payload.result; 217 | tester.test( 218 | 90, 219 | assert.ok, 220 | h1s.length === 1, 221 | `There should be 1 and only 1 H1 tag on the page. Currently: ${h1s.length}`, 222 | ); 223 | 224 | let titleArr; 225 | if (title[0]) { 226 | titleArr = cleanString(title[0].innerText) 227 | .split(' ') 228 | .filter((i) => [':', '|', '-'].indexOf(i) === -1); 229 | } 230 | 231 | if (h1s[0]) { 232 | tester.test(90, assert.notStrictEqual, h1s[0].innerText.length, 0, 'H1 tags should not be empty'); 233 | tester.lint( 234 | assert.ok, 235 | h1s[0].innerText.length < 70, 236 | `H1 tag is longer than the recommended limit of 70. (${h1s[0].innerText})`, 237 | ); 238 | tester.lint( 239 | assert.ok, 240 | h1s[0].innerText.length > 10, 241 | `H1 tag is shorter than the recommended limit of 10. (${h1s[0].innerText})`, 242 | ); 243 | 244 | if (titleArr) { 245 | const compareArr = cleanString(h1s[0].innerText) 246 | .split(' ') 247 | .filter((i) => [':', '|', '-'].indexOf(i) === -1); 248 | 249 | const matches = titleArr.filter((t) => compareArr.indexOf(t) !== -1); 250 | 251 | if (matches.length < 1) console.log(titleArr, compareArr); 252 | 253 | tester.lint(70, assert.ok, matches.length >= 1, `H1 tag should have at least 1 word from your title tag.`); 254 | } 255 | } else { 256 | tester.test(assert.ok, h2s.length === 0, `No h1 tag, but h2 tags are defined.`); 257 | tester.test(assert.ok, h3s.length === 0, `No h1 tag, but h3 tags are defined.`); 258 | } 259 | 260 | let usesKeywords = false; 261 | 262 | h2s.forEach((h2) => { 263 | tester.test(80, assert.notEqual, h2.innerText.length, 0, 'H2 tags should not be empty'); 264 | tester.lint( 265 | assert.ok, 266 | h2.innerText.length < 100, 267 | `H2 tag is longer than the recommended limit of 100. (${h2.innerText})`, 268 | ); 269 | tester.lint( 270 | assert.ok, 271 | h2.innerText.length > 7, 272 | `H2 tag is shorter than the recommended limit of 7. (${h2.innerText})`, 273 | ); 274 | 275 | const compareArr = cleanString(h2.innerText.toLowerCase()) 276 | .split(' ') 277 | .filter((i) => [':', '|', '-'].indexOf(i) === -1); 278 | 279 | if (titleArr) { 280 | const matches = titleArr.filter((t) => compareArr.indexOf(t) !== -1); 281 | if (matches.length > 0) { 282 | usesKeywords = true; 283 | } 284 | } 285 | }); 286 | 287 | if (h2s.length > 0 && title[0]) { 288 | tester.lint(70, assert.ok, usesKeywords, `None of your h2 tags use a single word from your title tag.`); 289 | } 290 | 291 | usesKeywords = false; 292 | h3s.forEach((h3) => { 293 | tester.test(70, assert.notStrictEqual, h3.innerText.length, 0, 'h3 tags should not be empty'); 294 | tester.lint( 295 | 20, 296 | assert.ok, 297 | h3.innerText.length < 100, 298 | `h3 tag is longer than the recommended limit of 100. (${h3.innerText})`, 299 | ); 300 | tester.lint( 301 | 20, 302 | assert.ok, 303 | h3.innerText.length > 7, 304 | `h3 tag is shorter than the recommended limit of 7. (${h3.innerText})`, 305 | ); 306 | 307 | // const arr = h3.innerText 308 | // .toLowerCase() 309 | // .split(' ') 310 | // .filter((i) => [':', '|', '-'].indexOf(i) === -1); 311 | 312 | // const matches = titleArr.filter((t) => arr.indexOf(t) !== -1); 313 | // if (matches.length > 0) { 314 | // usesKeywords = true; 315 | // } 316 | }); 317 | 318 | // if (h3s.length > 0) { 319 | // tester.lint( 320 | // 40, 321 | // assert.ok, 322 | // usesKeywords, 323 | // `None of your h3 tags use a single word from your title tag. Investigate.`, 324 | // ); 325 | // } 326 | 327 | h4s.forEach((h4) => { 328 | tester.test(50, assert.notEqual, h4.innerText.length, 0, 'h4 tags should not be empty'); 329 | tester.lint( 330 | 10, 331 | assert.ok, 332 | h4.innerText.length < 100, 333 | `h4 tag is longer than the recommended limit of 100. (${h4.innerText})`, 334 | ); 335 | tester.lint( 336 | 10, 337 | assert.ok, 338 | h4.innerText.length > 7, 339 | `h4 tag is shorter than the recommended limit of 7. (${h4.innerText})`, 340 | ); 341 | }); 342 | 343 | // check that we aren't overloading the htags or misusing their priority. 344 | tester.lint( 345 | 80, 346 | assert.ok, 347 | !(h2s.length > 0 && h1s.length === 0), 348 | `There are h2 tags but no h1 tag. Consider If you can move one of your h2s to an h1.`, 349 | ); 350 | tester.lint( 351 | 50, 352 | assert.ok, 353 | !(h3s.length > 0 && h2s.length === 0), 354 | `There are h3 tags but no h2 tags. Consider If you can move h3s to h2s.`, 355 | ); 356 | tester.lint( 357 | 30, 358 | assert.ok, 359 | !(h4s.length > 0 && h3s.length === 0), 360 | `There are h4 tags but no h3 tags. Consider If you can move h4s to h3s.`, 361 | ); 362 | tester.lint( 363 | 30, 364 | assert.ok, 365 | !(h5s.length > 0 && h4s.length === 0), 366 | `There are h5 tags but no h4 tags. Consider If you can move h5s to h4s.`, 367 | ); 368 | tester.lint( 369 | 30, 370 | assert.ok, 371 | !(h6s.length > 0 && h5s.length === 0), 372 | `There are h6 tags but no h5 tags. Consider If you can move h6s to h5s.`, 373 | ); 374 | }, 375 | }, 376 | { 377 | name: 'Viewport with Initial Scale 1.0', 378 | description: 379 | 'Page has a <meta name="viewport" content="width=device-width, initial-scale=1.0" />. This will allow users to zoom on your mobile page but won\'t be zoomed in by default.', 380 | testData: { 381 | preferences: defaultPreferences, 382 | response: { 383 | meta: [{ charset: 'utf-8' }, { name: 'viewport', content: 'width=device-width, initial-scale=1.0' }], 384 | }, 385 | }, 386 | validator: async (payload, tester) => { 387 | const viewport = payload.result.meta.find((m) => m.name === 'viewport'); 388 | if (viewport) { 389 | tester.test(assert.ok, !!viewport, `Meta viewport should be defined`); 390 | tester.test(assert.ok, !!viewport.content, `Meta viewport has a content attribute`); 391 | tester.test( 392 | assert.ok, 393 | viewport.content.includes('width=device-width'), 394 | `Meta viewport content includes width=device-width`, 395 | ); 396 | tester.lint( 397 | assert.ok, 398 | viewport.content.includes('initial-scale=1'), 399 | `Meta viewport content may want to include initial-scale=1`, 400 | ); 401 | } 402 | }, 403 | }, 404 | { 405 | name: 'Internal Links are well formed', 406 | description: 'Checks that all internal links are lowercase and have a trailing slash', 407 | testData: { 408 | preferences: defaultPreferences, 409 | response: { 410 | ok: true, 411 | url: 'https://nicholasreese.com/', 412 | }, 413 | 414 | result: { 415 | aTags: [ 416 | { 417 | tag: 'a', 418 | innerHTML: '← Home', 419 | innerText: '← Home', 420 | href: '/', 421 | class: 'svelte-bvr7j8', 422 | }, 423 | { 424 | tag: 'a', 425 | innerHTML: 'Elder.js', 426 | innerText: 'Elder.js', 427 | href: 'https://elderguide.com/tech/elderjs/', 428 | class: 'svelte-1tkpvyy', 429 | }, 430 | ], 431 | }, 432 | }, 433 | validator: async (payload, tester) => { 434 | const internal = payload.result.aTags 435 | .filter((l) => (payload.response.host && l.href.includes(payload.response.host)) || !l.href.includes('http')) 436 | .map((l) => { 437 | if (l.href.includes('#')) { 438 | l.href = l.href.split('#')[0]; 439 | } 440 | return l; 441 | }) 442 | .filter((l) => !l.href.includes('mailto') && l.href.length > 0); 443 | if (payload.preferences.internalLinksLowerCase) { 444 | internal.forEach((l) => { 445 | tester.lint( 446 | 80, 447 | assert.ok, 448 | l.href === l.href.toLowerCase(), 449 | `Internal links should be lowercase: [${l.innerText}](${l.href}) is not.`, 450 | ); 451 | }); 452 | } 453 | 454 | if (payload.preferences.internalLinksTrailingSlash) { 455 | internal.forEach((l) => { 456 | tester.lint( 457 | 80, 458 | assert.ok, 459 | l.href.endsWith('/'), 460 | `Internal links should include a trailing slash: [${l.innerText}](${l.href}) does not.`, 461 | ); 462 | }); 463 | } 464 | 465 | internal.forEach((l) => { 466 | tester.test( 467 | 100, 468 | assert.ok, 469 | l.ref !== 'nofollow', 470 | `Internal nofollow links are bad news. [${l.innerText}](${l.href})`, 471 | ); 472 | }); 473 | 474 | internal 475 | .filter((l) => l.href.includes('http')) 476 | .forEach((l) => { 477 | tester.test( 478 | assert.ok, 479 | l.href.includes('https'), 480 | `Internal links should use https: [${l.innerText}](${l.href}) does not.`, 481 | ); 482 | tester.test( 483 | 100, 484 | assert.ok, 485 | !l.href.includes('.html'), 486 | `Internal links should not link to .html documents: [${l.innerText}](${l.href}) does.`, 487 | ); 488 | }); 489 | }, 490 | }, 491 | { 492 | name: 'Outbound links', 493 | description: 'Checks for the number of outbound links', 494 | testData: { 495 | preferences: defaultPreferences, 496 | response: { 497 | ok: true, 498 | url: 'https://nicholasreese.com/', 499 | }, 500 | 501 | result: { 502 | aTags: [ 503 | { 504 | tag: 'a', 505 | innerHTML: '← Home', 506 | innerText: '← Home', 507 | href: '/', 508 | class: 'svelte-bvr7j8', 509 | }, 510 | { 511 | tag: 'a', 512 | innerHTML: 'Elder.js', 513 | innerText: 'Elder.js', 514 | href: 'https://elderguide.com/tech/elderjs/', 515 | class: 'svelte-1tkpvyy', 516 | }, 517 | { 518 | tag: 'a', 519 | innerHTML: 'Elder.js', 520 | innerText: 'Elder.js', 521 | href: 'https://elderguide.com/tech/elderjs/', 522 | class: 'svelte-1tkpvyy', 523 | }, 524 | { 525 | tag: 'a', 526 | innerHTML: 'Elder.js', 527 | innerText: 'Elder.js', 528 | href: 'https://elderguide.com/tech/elderjs/', 529 | class: 'svelte-1tkpvyy', 530 | }, 531 | { 532 | tag: 'a', 533 | innerHTML: 'Elder.js', 534 | innerText: 'Elder.js', 535 | href: 'https://elderguide.com/tech/elderjs/', 536 | class: 'svelte-1tkpvyy', 537 | }, 538 | { 539 | tag: 'a', 540 | innerHTML: 'Elder.js', 541 | innerText: 'Elder.js', 542 | href: 'https://elderguide.com/tech/elderjs/', 543 | class: 'svelte-1tkpvyy', 544 | }, 545 | ], 546 | }, 547 | }, 548 | validator: async (payload, tester) => { 549 | const external = payload.result.aTags.filter( 550 | (l) => !l.href.includes(payload.response.host) && l.href.includes('http'), 551 | ); 552 | 553 | tester.lint(assert.ok, external.length < 50, `Heads up, this page has more than 50 outbound links.`); 554 | }, 555 | }, 556 | 557 | { 558 | name: 'Images', 559 | description: 'Checks for alt tags on images.', 560 | testData: { 561 | preferences: defaultPreferences, 562 | response: { 563 | ok: true, 564 | url: 'https://nicholasreese.com/', 565 | }, 566 | 567 | result: { 568 | imgs: [ 569 | { 570 | tag: 'img', 571 | innerHTML: '', 572 | innerText: '', 573 | src: 'https://elderguide.com/images/elderjs-hooks-v100.png', 574 | alt: 'Elder.js hook Lifecycle', 575 | style: 'max-width:100%; margin:1rem 0;', 576 | }, 577 | ], 578 | }, 579 | }, 580 | validator: async (payload, tester) => { 581 | payload.result.imgs.forEach((i) => 582 | tester.lint(assert.ok, i.alt && i.alt.length > 0, `Images should have alt tags. ${i.src} does not`), 583 | ); 584 | }, 585 | }, 586 | ]; 587 | --------------------------------------------------------------------------------