Custom Rules

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

  1. Unique IDs - Use descriptive, kebab-case IDs
  2. Clear messages - Explain what's wrong
  3. Helpful suggestions - Tell users how to fix
  4. Appropriate severity - Use error sparingly
  5. Test thoroughly - Cover edge cases
  6. Document options - Explain configuration
  7. 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' }, }, }, });