Custom Rules
Create custom SEO rules for your specific requirements.
Rule Structure
import type { SEORule, AnalysisContext, SEOIssue } from '@capyseo/core';
const myRule: SEORule = {
id: 'my-custom-rule',
name: 'My Custom Rule',
description: 'Checks for custom requirement',
category: 'content',
defaultSeverity: 'warning',
check(context: AnalysisContext): SEOIssue[] {
const issues: SEOIssue[] = [];
// Your logic here
return issues;
},
};
Basic Example
Check that all pages have a specific meta tag:
import type { SEORule, AnalysisContext, SEOIssue } from '@capyseo/core';
export const authorMetaRule: SEORule = {
id: 'author-meta',
name: 'Author Meta Tag',
description: 'Pages should have author meta tag',
category: 'meta-tags',
defaultSeverity: 'warning',
check(context: AnalysisContext): SEOIssue[] {
const { $ } = context;
const issues: SEOIssue[] = [];
const author = $('meta[name="author"]').attr('content');
if (!author) {
issues.push({
ruleId: 'author-meta',
message: 'Missing author meta tag',
severity: 'warning',
suggestion: 'Add <meta name="author" content="Your Name">',
});
}
return issues;
},
};
Context Object
The check function receives an AnalysisContext:
interface AnalysisContext {
// Cheerio instance for DOM queries
$: CheerioAPI;
// Raw HTML content
html: string;
// Page URL or file path
url: string;
// Page metadata
metadata: {
title?: string;
description?: string;
canonical?: string;
};
// Configuration
config: CapyseoConfig;
}
Using Cheerio
The $ object is a Cheerio instance for jQuery-like DOM queries:
check(context: AnalysisContext): SEOIssue[] {
const { $ } = context;
// Select elements
const title = $('title').text();
const h1 = $('h1').first().text();
const images = $('img');
const links = $('a[href]');
// Check attributes
const canonical = $('link[rel="canonical"]').attr('href');
const robots = $('meta[name="robots"]').attr('content');
// Iterate elements
$('img').each((i, el) => {
const alt = $(el).attr('alt');
const src = $(el).attr('src');
});
// Count elements
const h1Count = $('h1').length;
return [];
}
SEOIssue Object
Return issues with this structure:
interface SEOIssue {
// Rule ID (matches rule.id)
ruleId: string;
// Human-readable message
message: string;
// Severity level
severity: 'error' | 'warning' | 'info';
// Optional: How to fix
suggestion?: string;
// Optional: Element selector
selector?: string;
// Optional: Additional context
context?: Record<string, unknown>;
}
Advanced Examples
Word Count Check
export const minWordCountRule: SEORule = {
id: 'min-word-count',
name: 'Minimum Word Count',
description: 'Content should have minimum word count',
category: 'content',
defaultSeverity: 'warning',
check(context: AnalysisContext): SEOIssue[] {
const { $ } = context;
const minWords = context.config.rules?.['min-word-count']?.options?.min ?? 300;
// Get text content (excluding scripts, styles)
const text = $('body')
.clone()
.find('script, style, nav, footer, header')
.remove()
.end()
.text();
const wordCount = text.split(/\s+/).filter(Boolean).length;
if (wordCount < minWords) {
return [{
ruleId: 'min-word-count',
message: `Content has ${wordCount} words (minimum: ${minWords})`,
severity: 'warning',
suggestion: `Add more content to reach at least ${minWords} words`,
context: { wordCount, minWords },
}];
}
return [];
},
};
Brand Consistency
export const brandNameRule: SEORule = {
id: 'brand-name',
name: 'Brand Name in Title',
description: 'Title should include brand name',
category: 'meta-tags',
defaultSeverity: 'warning',
check(context: AnalysisContext): SEOIssue[] {
const { $ } = context;
const brandName = context.config.rules?.['brand-name']?.options?.name;
if (!brandName) return [];
const title = $('title').text();
if (!title.includes(brandName)) {
return [{
ruleId: 'brand-name',
message: `Title missing brand name "${brandName}"`,
severity: 'warning',
suggestion: `Add "${brandName}" to the page title`,
}];
}
return [];
},
};
Link Target Validation
export const externalLinkTargetRule: SEORule = {
id: 'external-link-target',
name: 'External Link Target',
description: 'External links should open in new tab',
category: 'links',
defaultSeverity: 'info',
check(context: AnalysisContext): SEOIssue[] {
const { $, url } = context;
const issues: SEOIssue[] = [];
const currentHost = new URL(url).hostname;
$('a[href^="http"]').each((i, el) => {
const href = $(el).attr('href');
const target = $(el).attr('target');
try {
const linkHost = new URL(href!).hostname;
if (linkHost !== currentHost && target !== '_blank') {
issues.push({
ruleId: 'external-link-target',
message: `External link missing target="_blank": ${href}`,
severity: 'info',
selector: `a[href="${href}"]`,
suggestion: 'Add target="_blank" rel="noopener" to external links',
});
}
} catch {}
});
return issues;
},
};
Registering Custom Rules
Programmatic API
import { SEOAnalyzer } from '@capyseo/core';
import { authorMetaRule, minWordCountRule } from './my-rules';
const analyzer = new SEOAnalyzer({
customRules: [authorMetaRule, minWordCountRule],
});
const results = await analyzer.analyze('./dist');
With Options
const analyzer = new SEOAnalyzer({
customRules: [authorMetaRule, minWordCountRule],
rules: {
'author-meta': { severity: 'error' },
'min-word-count': {
severity: 'warning',
options: { min: 500 },
},
},
});
Rule Options
Accept custom options via config:
export const keywordDensityRule: SEORule = {
id: 'keyword-density',
name: 'Keyword Density',
description: 'Check keyword density is within range',
category: 'content',
defaultSeverity: 'warning',
check(context: AnalysisContext): SEOIssue[] {
const options = context.config.rules?.['keyword-density']?.options ?? {};
const minDensity = options.min ?? 1;
const maxDensity = options.max ?? 3;
const keyword = options.keyword;
if (!keyword) return [];
// Calculate density...
return [];
},
};
Config:
// capyseo.config.js
export default {
rules: {
'keyword-density': {
severity: 'warning',
options: {
keyword: 'react',
min: 1,
max: 2.5,
},
},
},
};
Testing Rules
import { describe, it, expect } from 'vitest';
import * as cheerio from 'cheerio';
import { authorMetaRule } from './my-rules';
describe('authorMetaRule', () => {
it('returns issue when author meta missing', () => {
const html = '<html><head></head><body></body></html>';
const $ = cheerio.load(html);
const issues = authorMetaRule.check({
$,
html,
url: 'https://example.com',
metadata: {},
config: {},
});
expect(issues).toHaveLength(1);
expect(issues[0].ruleId).toBe('author-meta');
});
it('passes when author meta present', () => {
const html = '<html><head><meta name="author" content="John"></head></html>';
const $ = cheerio.load(html);
const issues = authorMetaRule.check({
$,
html,
url: 'https://example.com',
metadata: {},
config: {},
});
expect(issues).toHaveLength(0);
});
});
Categories
Use standard categories for consistency:
| Category |
Description |
meta-tags |
Title, description, viewport |
images |
Alt text, dimensions, format |
headings |
H1-H6 hierarchy |
links |
Internal, external, broken |
content |
Word count, readability |
social |
Open Graph, Twitter |
structured-data |
JSON-LD, microdata |
mobile |
Mobile-friendliness |
security |
HTTPS, headers |
performance |
Loading, resources |
url |
Structure, length |
Best Practices
- Unique IDs - Use descriptive, kebab-case IDs
- Clear messages - Explain what's wrong
- Helpful suggestions - Tell users how to fix
- Appropriate severity - Use error sparingly
- Test thoroughly - Cover edge cases
- Document options - Explain configuration
- Handle errors - Don't crash on bad input
Example: Full Rule File
// rules/brand-rules.ts
import type { SEORule, AnalysisContext, SEOIssue } from '@capyseo/core';
export const brandTitleRule: SEORule = {
id: 'brand-title',
name: 'Brand in Title',
description: 'Page title should include brand name',
category: 'meta-tags',
defaultSeverity: 'warning',
check(context: AnalysisContext): SEOIssue[] {
const { $ } = context;
const brand = context.config.rules?.['brand-title']?.options?.brand;
if (!brand) return [];
const title = $('title').text();
if (!title.toLowerCase().includes(brand.toLowerCase())) {
return [{
ruleId: 'brand-title',
message: `Title missing brand "${brand}"`,
severity: 'warning',
suggestion: `Include "${brand}" in the page title`,
}];
}
return [];
},
};
export const brandRules = [brandTitleRule];
Usage:
import { SEOAnalyzer } from '@capyseo/core';
import { brandRules } from './rules/brand-rules';
const analyzer = new SEOAnalyzer({
customRules: brandRules,
rules: {
'brand-title': {
severity: 'error',
options: { brand: 'Capyseo' },
},
},
});