@qobi/seocode 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/LICENSE +21 -0
  2. package/dist/src/billing/polar.js +127 -0
  3. package/dist/src/billing/repo-limits.js +60 -0
  4. package/dist/src/billing/subscription-service.js +123 -0
  5. package/dist/src/billing/tier.js +14 -0
  6. package/dist/src/billing/types.js +1 -0
  7. package/dist/src/cli/discover.js +62 -0
  8. package/dist/src/cli/fix.js +24 -0
  9. package/dist/src/cli/format-terminal.js +66 -0
  10. package/dist/src/cli/index.js +204 -0
  11. package/dist/src/cli/init.js +48 -0
  12. package/dist/src/cli/staged.js +24 -0
  13. package/dist/src/config/seocode-config.js +97 -0
  14. package/dist/src/engine/page-detector.js +82 -0
  15. package/dist/src/engine/pr-delta.js +31 -0
  16. package/dist/src/engine/rule-engine.js +112 -0
  17. package/dist/src/engine/rule-loader.js +35 -0
  18. package/dist/src/engine/rules/declarative-evaluator.js +105 -0
  19. package/dist/src/engine/rules/heading-rules.js +43 -0
  20. package/dist/src/engine/rules/helpers.js +16 -0
  21. package/dist/src/engine/rules/image-rules.js +82 -0
  22. package/dist/src/engine/rules/index.js +28 -0
  23. package/dist/src/engine/rules/link-rules.js +39 -0
  24. package/dist/src/engine/rules/meta-rules.js +34 -0
  25. package/dist/src/engine/rules/performance-rules.js +24 -0
  26. package/dist/src/engine/rules/schema-rules.js +228 -0
  27. package/dist/src/engine/rules/suggest.js +103 -0
  28. package/dist/src/engine/rules/technical-rules.js +100 -0
  29. package/dist/src/parsers/frameworks/ast-value.js +83 -0
  30. package/dist/src/parsers/frameworks/astro.js +107 -0
  31. package/dist/src/parsers/frameworks/index.js +36 -0
  32. package/dist/src/parsers/frameworks/nextjs.js +170 -0
  33. package/dist/src/parsers/frameworks/remix.js +130 -0
  34. package/dist/src/parsers/frameworks/roles.js +63 -0
  35. package/dist/src/parsers/frameworks/types.js +1 -0
  36. package/dist/src/parsers/html-parser.js +118 -0
  37. package/dist/src/parsers/index.js +62 -0
  38. package/dist/src/parsers/jsx-parser.js +174 -0
  39. package/dist/src/types/index.js +1 -0
  40. package/dist/src/types/worker-env.js +1 -0
  41. package/package.json +75 -0
  42. package/rules/seo-rules.json +3414 -0
@@ -0,0 +1,204 @@
1
+ #!/usr/bin/env node
2
+ import * as fs from 'fs';
3
+ import * as path from 'path';
4
+ import { fileURLToPath } from 'url';
5
+ import { parseFile } from '../parsers/index.js';
6
+ import { analyseDocument } from '../engine/rule-engine.js';
7
+ import { parseConfig, applyConfigToRules } from '../config/seocode-config.js';
8
+ import { discoverFiles } from './discover.js';
9
+ import { formatTerminal } from './format-terminal.js';
10
+ import { applyFixes } from './fix.js';
11
+ import { stagedFiles } from './staged.js';
12
+ import { runInit } from './init.js';
13
+ const VERSION = '0.1.0';
14
+ function parseArgs(argv) {
15
+ const a = {
16
+ command: 'check', paths: [], json: false,
17
+ color: process.stdout.isTTY && !process.env.NO_COLOR,
18
+ fix: false, staged: false, hook: false, help: false, version: false,
19
+ };
20
+ let sawCommand = false;
21
+ for (const arg of argv) {
22
+ if (arg === '--json')
23
+ a.json = true;
24
+ else if (arg === '--no-color')
25
+ a.color = false;
26
+ else if (arg === '--color')
27
+ a.color = true;
28
+ else if (arg === '--fix')
29
+ a.fix = true;
30
+ else if (arg === '--staged')
31
+ a.staged = true;
32
+ else if (arg === '--hook')
33
+ a.hook = true;
34
+ else if (arg === '-h' || arg === '--help')
35
+ a.help = true;
36
+ else if (arg === '-v' || arg === '--version')
37
+ a.version = true;
38
+ else if (arg.startsWith('-')) { /* unknown flag — ignore for now */ }
39
+ else if (!sawCommand && ['check', 'fix', 'init', 'help', 'version'].includes(arg)) {
40
+ a.command = arg;
41
+ sawCommand = true;
42
+ }
43
+ else
44
+ a.paths.push(arg);
45
+ }
46
+ if (a.command === 'fix')
47
+ a.fix = true; // `seocode fix` == `seocode check --fix`
48
+ if (a.command === 'help')
49
+ a.help = true;
50
+ if (a.command === 'version')
51
+ a.version = true;
52
+ return a;
53
+ }
54
+ const HELP = `
55
+ seocode — technical SEO review for your codebase
56
+
57
+ Usage
58
+ seocode check [paths...] Scan the repo (or given paths) for SEO issues
59
+ seocode check --fix Scan, then apply safe 1-click fixes to your files
60
+ seocode check --staged Scan only files staged for commit (pre-commit mode)
61
+ seocode init Create .seocode.json and set up a pre-commit hook
62
+ seocode --help Show this help
63
+
64
+ Options
65
+ --fix Apply provably-safe fixes (e.g. loading="lazy", rel="noopener")
66
+ --staged Review only git-staged files
67
+ --json Machine-readable JSON output
68
+ --no-color Disable ANSI colors (also respects NO_COLOR)
69
+ --hook (with init) install .git/hooks/pre-commit
70
+
71
+ Behavior
72
+ • Zero config — scans HTML, JS, JSX, TSX, Vue, Svelte and Astro files.
73
+ • Framework-aware — understands Next.js / Remix metadata, so it won't
74
+ false-flag dynamic titles, descriptions, or JSON-LD.
75
+ • Honors a .seocode.json in the repo root (exclude globs, rule overrides).
76
+ • Exit code 1 when deploy-blocking (critical) issues remain — drops straight
77
+ into a pre-commit hook or CI step.
78
+ `;
79
+ /** Locate the bundled ruleset by walking up from this module. */
80
+ function findRulesFile() {
81
+ let dir = path.dirname(fileURLToPath(import.meta.url));
82
+ for (let i = 0; i < 10; i++) {
83
+ const candidate = path.join(dir, 'rules', 'seo-rules.json');
84
+ if (fs.existsSync(candidate))
85
+ return candidate;
86
+ const parent = path.dirname(dir);
87
+ if (parent === dir)
88
+ break;
89
+ dir = parent;
90
+ }
91
+ throw new Error('Could not locate rules/seo-rules.json (is the package intact?)');
92
+ }
93
+ async function analyseOne(file, rules) {
94
+ let content;
95
+ try {
96
+ content = fs.readFileSync(file, 'utf8');
97
+ }
98
+ catch {
99
+ return [];
100
+ }
101
+ const doc = parseFile(file, content);
102
+ if (!doc)
103
+ return [];
104
+ return (await analyseDocument(doc, rules)).issues;
105
+ }
106
+ async function main() {
107
+ const args = parseArgs(process.argv.slice(2));
108
+ if (args.help) {
109
+ process.stdout.write(HELP);
110
+ return 0;
111
+ }
112
+ if (args.version) {
113
+ process.stdout.write(`seocode ${VERSION}\n`);
114
+ return 0;
115
+ }
116
+ const root = process.cwd();
117
+ if (args.command === 'init') {
118
+ process.stdout.write(runInit(root, { hook: args.hook }).output);
119
+ return 0;
120
+ }
121
+ // ── Config (.seocode.json) ──
122
+ let config = { exclude: [], rules: {} };
123
+ const cfgPath = path.join(root, '.seocode.json');
124
+ if (fs.existsSync(cfgPath))
125
+ config = parseConfig(fs.readFileSync(cfgPath, 'utf8'));
126
+ // ── Rules (bundled JSON, config overrides applied) ──
127
+ const allRules = JSON.parse(fs.readFileSync(findRulesFile(), 'utf8')).rules;
128
+ const rules = applyConfigToRules(allRules.filter(r => r.enabled), config);
129
+ // ── Discover files (repo scan, explicit paths, or staged set) ──
130
+ let inputs = args.paths;
131
+ if (args.staged) {
132
+ const staged = stagedFiles(root);
133
+ if (staged.length === 0) {
134
+ process.stdout.write('No staged files to review.\n');
135
+ return 0;
136
+ }
137
+ inputs = staged;
138
+ }
139
+ const files = discoverFiles(root, inputs, config.exclude);
140
+ // ── Optional: apply fixes first, then report the post-fix state ──
141
+ let fixedCount = 0;
142
+ let fixedFiles = 0;
143
+ if (args.fix) {
144
+ for (const file of files) {
145
+ let content;
146
+ try {
147
+ content = fs.readFileSync(file, 'utf8');
148
+ }
149
+ catch {
150
+ continue;
151
+ }
152
+ const doc = parseFile(file, content);
153
+ if (!doc)
154
+ continue;
155
+ const issues = (await analyseDocument(doc, rules)).issues;
156
+ const { content: fixed, applied } = applyFixes(content, issues);
157
+ if (applied > 0) {
158
+ fs.writeFileSync(file, fixed);
159
+ fixedCount += applied;
160
+ fixedFiles++;
161
+ }
162
+ }
163
+ }
164
+ // ── Analyse (post-fix if we fixed) and report ──
165
+ const fileReports = [];
166
+ let critical = 0, warning = 0, info = 0;
167
+ for (const file of files) {
168
+ const issues = await analyseOne(file, rules);
169
+ if (issues.length === 0)
170
+ continue;
171
+ fileReports.push({ file, issues });
172
+ for (const i of issues) {
173
+ if (i.severity === 'critical')
174
+ critical++;
175
+ else if (i.severity === 'warning')
176
+ warning++;
177
+ else
178
+ info++;
179
+ }
180
+ }
181
+ const report = { files: fileReports, scanned: files.length, critical, warning, info };
182
+ if (args.json) {
183
+ const flat = fileReports.flatMap(f => f.issues.map(i => ({
184
+ file: relOf(root, f.file), line: i.line, severity: i.severity,
185
+ ruleId: i.ruleId, ruleName: i.ruleName, description: i.description, fix: i.fix,
186
+ })));
187
+ process.stdout.write(JSON.stringify({ scanned: files.length, fixed: fixedCount, critical, warning, info, issues: flat }, null, 2) + '\n');
188
+ }
189
+ else {
190
+ if (fixedCount > 0) {
191
+ process.stdout.write(`\n ✔ Applied ${fixedCount} fix${fixedCount === 1 ? '' : 'es'} across ${fixedFiles} file${fixedFiles === 1 ? '' : 's'}.\n`);
192
+ }
193
+ process.stdout.write(formatTerminal(report, { color: args.color, root }));
194
+ }
195
+ return critical > 0 ? 1 : 0;
196
+ }
197
+ function relOf(root, file) {
198
+ const rel = path.relative(root, file).split(path.sep).join('/');
199
+ return !rel || rel.startsWith('..') ? file : rel;
200
+ }
201
+ main().then(code => process.exit(code)).catch(err => {
202
+ process.stderr.write(`seocode: ${err instanceof Error ? err.message : String(err)}\n`);
203
+ process.exit(2);
204
+ });
@@ -0,0 +1,48 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ /**
4
+ * `seocode init` — scaffolds a `.seocode.json` (if absent) and prints how to wire
5
+ * a pre-commit hook. We intentionally generate a **pre-commit hook**, not a
6
+ * GitHub Action: the hosted GitHub App is the automated surface (no YAML, no CI
7
+ * runner minutes), and the CLI is the local one. `--hook` installs a native git
8
+ * hook; without it we just print the snippet so nothing is written unasked.
9
+ */
10
+ export function runInit(root, opts) {
11
+ const lines = [];
12
+ const cfgPath = path.join(root, '.seocode.json');
13
+ let wroteConfig = false;
14
+ if (fs.existsSync(cfgPath)) {
15
+ lines.push('• .seocode.json already exists — left untouched.');
16
+ }
17
+ else {
18
+ fs.writeFileSync(cfgPath, JSON.stringify({ exclude: [], rules: {} }, null, 2) + '\n');
19
+ wroteConfig = true;
20
+ lines.push('✔ Created .seocode.json');
21
+ }
22
+ lines.push('');
23
+ lines.push('Config options (all optional):');
24
+ lines.push(' "exclude": ["emails/**", "public/legacy/**"] skip paths (globs)');
25
+ lines.push(' "rules": { "title-too-short": "off", disable a rule');
26
+ lines.push(' "missing-canonical": "info" } or change its severity');
27
+ lines.push('');
28
+ const hookBody = '#!/bin/sh\nnpx seocode check --staged\n';
29
+ const hookPath = path.join(root, '.git', 'hooks', 'pre-commit');
30
+ if (opts.hook) {
31
+ try {
32
+ fs.mkdirSync(path.dirname(hookPath), { recursive: true });
33
+ fs.writeFileSync(hookPath, hookBody);
34
+ fs.chmodSync(hookPath, 0o755);
35
+ lines.push('✔ Installed .git/hooks/pre-commit → runs `seocode check --staged` before every commit.');
36
+ }
37
+ catch (err) {
38
+ lines.push(`• Could not install the git hook (${err instanceof Error ? err.message : String(err)}).`);
39
+ }
40
+ }
41
+ else {
42
+ lines.push('Add a pre-commit hook to catch issues before you push:');
43
+ lines.push(' seocode init --hook # installs .git/hooks/pre-commit for you');
44
+ lines.push(' # …or with husky: echo "npx seocode check --staged" > .husky/pre-commit');
45
+ }
46
+ lines.push('');
47
+ return { output: lines.join('\n') + '\n', wroteConfig };
48
+ }
@@ -0,0 +1,24 @@
1
+ import { execFileSync } from 'child_process';
2
+ import * as path from 'path';
3
+ /**
4
+ * Absolute paths of the files staged for commit (added/copied/modified). Powers
5
+ * `--staged` so the CLI drops into a pre-commit hook and reviews only what's
6
+ * about to be committed. Throws a clear message when there's no git repo.
7
+ */
8
+ export function stagedFiles(root) {
9
+ let out;
10
+ try {
11
+ out = execFileSync('git', ['diff', '--cached', '--name-only', '--diff-filter=ACM'], {
12
+ cwd: root,
13
+ encoding: 'utf8',
14
+ });
15
+ }
16
+ catch {
17
+ throw new Error('--staged needs a git repository. Run it inside a repo with staged changes.');
18
+ }
19
+ return out
20
+ .split('\n')
21
+ .map(s => s.trim())
22
+ .filter(Boolean)
23
+ .map(f => path.resolve(root, f));
24
+ }
@@ -0,0 +1,97 @@
1
+ const VALID_SEVERITIES = new Set(['critical', 'warning', 'info']);
2
+ export const EMPTY_CONFIG = { exclude: [], rules: {} };
3
+ /**
4
+ * Parses raw `.seocode.json` text into a validated config. Never throws — any
5
+ * malformed input falls back to EMPTY_CONFIG, and individual invalid entries are
6
+ * dropped rather than failing the whole file.
7
+ */
8
+ export function parseConfig(raw) {
9
+ if (!raw)
10
+ return EMPTY_CONFIG;
11
+ let data;
12
+ try {
13
+ data = JSON.parse(raw);
14
+ }
15
+ catch {
16
+ return EMPTY_CONFIG;
17
+ }
18
+ if (typeof data !== 'object' || data === null)
19
+ return EMPTY_CONFIG;
20
+ const obj = data;
21
+ const exclude = Array.isArray(obj.exclude)
22
+ ? obj.exclude.filter((p) => typeof p === 'string' && p.length > 0)
23
+ : [];
24
+ const rules = {};
25
+ if (typeof obj.rules === 'object' && obj.rules !== null) {
26
+ for (const [id, val] of Object.entries(obj.rules)) {
27
+ if (val === false || val === 'off') {
28
+ rules[id] = 'off';
29
+ }
30
+ else if (typeof val === 'string' && VALID_SEVERITIES.has(val)) {
31
+ rules[id] = val;
32
+ }
33
+ // anything else (true, numbers, junk) is ignored
34
+ }
35
+ }
36
+ return { exclude, rules };
37
+ }
38
+ /**
39
+ * Applies rule overrides to the ruleset: disabled rules are dropped, severity
40
+ * overrides are applied. Returns a new array; input rules are not mutated.
41
+ */
42
+ export function applyConfigToRules(rules, config) {
43
+ const out = [];
44
+ for (const rule of rules) {
45
+ const override = config.rules[rule.id];
46
+ if (override === 'off')
47
+ continue; // disabled — drop it
48
+ if (override && VALID_SEVERITIES.has(override)) {
49
+ out.push({ ...rule, severity: override });
50
+ }
51
+ else {
52
+ out.push(rule);
53
+ }
54
+ }
55
+ return out;
56
+ }
57
+ /**
58
+ * Converts a single glob to a RegExp. Supports `**` (any characters, incl. `/`),
59
+ * `*` (any characters except `/`), and `?` (one non-`/` char). A trailing `/`
60
+ * matches the directory and everything under it.
61
+ */
62
+ function globToRegExp(glob) {
63
+ let g = glob;
64
+ if (g.endsWith('/'))
65
+ g += '**';
66
+ let re = '';
67
+ for (let i = 0; i < g.length; i++) {
68
+ const c = g[i];
69
+ if (c === '*') {
70
+ if (g[i + 1] === '*') {
71
+ re += '.*';
72
+ i++;
73
+ // consume a slash right after ** so "a/**/b" also matches "a/b"
74
+ if (g[i + 1] === '/')
75
+ i++;
76
+ }
77
+ else {
78
+ re += '[^/]*';
79
+ }
80
+ }
81
+ else if (c === '?') {
82
+ re += '[^/]';
83
+ }
84
+ else if ('.+^${}()|[]\\'.includes(c)) {
85
+ re += '\\' + c;
86
+ }
87
+ else {
88
+ re += c;
89
+ }
90
+ }
91
+ return new RegExp('^' + re + '$');
92
+ }
93
+ /** True when `path` matches any of the exclude globs. */
94
+ export function isPathExcluded(path, patterns) {
95
+ const normalized = path.replace(/^\.?\//, '');
96
+ return patterns.some(p => globToRegExp(p.replace(/^\.?\//, '')).test(normalized));
97
+ }
@@ -0,0 +1,82 @@
1
+ const PAGE_CONFIDENCE_THRESHOLD = 3;
2
+ // Filename patterns that strongly indicate a non-page file
3
+ const NON_PAGE_FILENAME_PATTERNS = [
4
+ /constants?\./i,
5
+ /utils?\./i,
6
+ /helpers?\./i,
7
+ /types?\./i,
8
+ /config\./i,
9
+ /hooks?\./i,
10
+ /context\./i,
11
+ /store\./i,
12
+ /slice\./i,
13
+ /reducer\./i,
14
+ /actions?\./i,
15
+ /selectors?\./i,
16
+ /services?\./i,
17
+ /api\./i,
18
+ /mock\./i,
19
+ /test\./i,
20
+ /spec\./i,
21
+ ];
22
+ // Framework shell mount point selectors
23
+ const FRAMEWORK_MOUNT_PATTERNS = ['id="root"', 'id="app"', 'id="__next"', 'id="__nuxt"', '<app-root'];
24
+ export function scorePageConfidence(doc) {
25
+ let score = 0;
26
+ const filename = doc.filePath.split('/').pop() ?? '';
27
+ // --- Positive signals ---
28
+ // Strong structural signals — present even in SEO-broken pages
29
+ if (doc.fileType === 'html') {
30
+ if (doc.rawContent.includes('<!DOCTYPE') || doc.rawContent.includes('<!doctype'))
31
+ score += 3;
32
+ if (/<body[\s>]/i.test(doc.rawContent))
33
+ score += 2;
34
+ if (/<head[\s>]/i.test(doc.rawContent))
35
+ score += 1;
36
+ }
37
+ else {
38
+ // JSX/TSX/Vue/Svelte page signals
39
+ if (/<html[\s>]/i.test(doc.rawContent))
40
+ score += 3;
41
+ if (/<head[\s>]/i.test(doc.rawContent))
42
+ score += 2;
43
+ if (/<body[\s>]/i.test(doc.rawContent))
44
+ score += 1;
45
+ }
46
+ // SEO meta signals
47
+ if (doc.titleTag)
48
+ score += 3;
49
+ if (doc.metaDescription)
50
+ score += 3;
51
+ if (doc.langAttribute)
52
+ score += 2;
53
+ if (doc.viewportMeta)
54
+ score += 1;
55
+ if (doc.charsetMeta)
56
+ score += 1;
57
+ if (doc.canonicalUrl)
58
+ score += 1;
59
+ if (doc.ogTitle || doc.ogDescription)
60
+ score += 2;
61
+ if (doc.headings.some((h) => h.tag === 'h1'))
62
+ score += 1;
63
+ // Has both a heading structure and some meta signal — looks like a real page
64
+ if (doc.headings.length > 0 && (doc.titleTag || doc.metaDescription))
65
+ score += 2;
66
+ // --- Negative signals ---
67
+ // Framework shell (React/Vue/Angular/Svelte app entry HTML)
68
+ if (FRAMEWORK_MOUNT_PATTERNS.some((p) => doc.rawContent.includes(p)))
69
+ score -= 5;
70
+ // Clearly a utility/config/type file by name
71
+ if (NON_PAGE_FILENAME_PATTERNS.some((pattern) => pattern.test(filename)))
72
+ score -= 4;
73
+ // No content signals at all — likely a component stub or data file
74
+ if (doc.headings.length === 0 && doc.images.length === 0 && doc.links.length === 0)
75
+ score -= 2;
76
+ return score;
77
+ }
78
+ export function isPageDocument(doc) {
79
+ // HTML files always count unless they are framework shells (handled by score)
80
+ // JSX/TSX/Vue/Svelte files need to earn enough page signals
81
+ return scorePageConfidence(doc) >= PAGE_CONFIDENCE_THRESHOLD;
82
+ }
@@ -0,0 +1,31 @@
1
+ export function splitByDelta(issues, addedLines, // filename -> Set(new-file line numbers this PR added)
2
+ addedFiles, // files whose PR status is "added"
3
+ prFiles) {
4
+ const introduced = [];
5
+ const preexisting = [];
6
+ for (const issue of issues) {
7
+ if (isIntroduced(issue, addedLines, addedFiles, prFiles))
8
+ introduced.push(issue);
9
+ else
10
+ preexisting.push(issue);
11
+ }
12
+ return { introduced, preexisting };
13
+ }
14
+ function isIntroduced(issue, addedLines, addedFiles, prFiles) {
15
+ // A brand-new file: everything in it is new.
16
+ if (addedFiles.has(issue.file))
17
+ return true;
18
+ // A file the PR never touched: pre-existing by definition.
19
+ if (!prFiles.has(issue.file))
20
+ return false;
21
+ // A file-level finding (no line) on a modified file can't be diff-located —
22
+ // don't blame the PR for something we can't attribute to its changes.
23
+ if (issue.line == null)
24
+ return false;
25
+ // Otherwise: introduced iff the finding sits on a line this PR added.
26
+ return addedLines.get(issue.file)?.has(issue.line) ?? false;
27
+ }
28
+ /** Convenience: how many introduced issues are criticals (drives the merge gate). */
29
+ export function introducedCriticalCount(split) {
30
+ return split.introduced.filter(i => i.severity === 'critical').length;
31
+ }
@@ -0,0 +1,112 @@
1
+ import { loadRules } from './rule-loader.js';
2
+ import { runRule } from './rules/index.js';
3
+ import { filterRulesForPlan } from '../billing/tier.js';
4
+ import { EMPTY_CONFIG, applyConfigToRules } from '../config/seocode-config.js';
5
+ import { attachSuggestions } from './rules/suggest.js';
6
+ // Only reached when preloadedRules is omitted — i.e. in tests, where loadRules
7
+ // is mocked and never touches this stub. In production preloadedRules is always
8
+ // supplied, so this is never invoked; the stub keeps types honest and fails loud
9
+ // if that assumption is ever violated.
10
+ const NO_KV = {
11
+ get: () => { throw new Error('loadRules called without a KV store — preloadedRules should always be provided in production'); },
12
+ put: async () => { },
13
+ delete: async () => { },
14
+ };
15
+ // Categories that only apply to full page documents — not utility/component files.
16
+ const PAGE_ONLY_CATEGORIES = new Set(['meta', 'headings', 'schema', 'technical', 'performance']);
17
+ // Framework shells (React/Vue/Svelte entry HTML) render content at runtime.
18
+ // These rules are skipped because content is managed per-page by the framework.
19
+ const FRAMEWORK_SHELL_SKIP_IDS = new Set([
20
+ 'missing-h1',
21
+ 'multiple-h1',
22
+ 'heading-hierarchy',
23
+ 'empty-heading',
24
+ 'missing-json-ld',
25
+ ]);
26
+ /**
27
+ * Whether a rule's declared file types cover this document. Astro templates are
28
+ * HTML-parsed, so a rule that targets `html` also applies to `astro` — this
29
+ * avoids having to add "astro" to all ~113 HTML rules in the JSON.
30
+ */
31
+ function appliesToFileType(rule, fileType) {
32
+ if (rule.appliesTo.includes(fileType))
33
+ return true;
34
+ if (fileType === 'astro' && rule.appliesTo.includes('html'))
35
+ return true;
36
+ return false;
37
+ }
38
+ function isSkipped(rule, doc) {
39
+ // A route handler / OG-image route emits no HTML page — no rule applies.
40
+ if (doc.framework?.role === 'route')
41
+ return true;
42
+ if (!appliesToFileType(rule, doc.fileType))
43
+ return true;
44
+ if (!doc.isPageDocument && PAGE_ONLY_CATEGORIES.has(rule.category))
45
+ return true;
46
+ if (doc.isFrameworkShell && FRAMEWORK_SHELL_SKIP_IDS.has(rule.id))
47
+ return true;
48
+ // Framework-aware scoping (Next.js, Remix, …): a rule is skipped when the
49
+ // framework provides it another way (metadata API / injected default) or when
50
+ // its subject lives in a sibling file for this file's role (layout vs page).
51
+ const fw = doc.framework;
52
+ if (fw) {
53
+ if (fw.suppressedRuleIds.includes(rule.id))
54
+ return true;
55
+ if (fw.suppressedCategories.includes(rule.category))
56
+ return true;
57
+ }
58
+ return false;
59
+ }
60
+ /**
61
+ * Analyses a single document against a given set of rules.
62
+ *
63
+ * When `preloadedRules` is provided (always the case in production — passed
64
+ * from analyseDocuments after plan-filtering), rules are used directly.
65
+ *
66
+ * When `preloadedRules` is omitted (direct call in tests), rules are loaded
67
+ * via loadRules — the test mock intercepts this and returns the full ruleset.
68
+ */
69
+ export async function analyseDocument(doc, preloadedRules) {
70
+ const rules = preloadedRules ?? (await loadRules(NO_KV)).rules;
71
+ const applicableRules = rules.filter(r => r.enabled && !isSkipped(r, doc));
72
+ const skippedRules = rules.filter(r => r.enabled && isSkipped(r, doc)).map(r => r.id);
73
+ const issues = [];
74
+ const passedRules = [];
75
+ for (const rule of applicableRules) {
76
+ const result = runRule(rule, doc);
77
+ if (result === null) {
78
+ passedRules.push(rule.id);
79
+ }
80
+ else {
81
+ issues.push(...(Array.isArray(result) ? result : [result]));
82
+ }
83
+ }
84
+ // Attach insertion-style 1-click fixes to declarative, file-level issues
85
+ // (per-element fixes are already attached by their rule handlers).
86
+ attachSuggestions(doc, issues);
87
+ return { file: doc.filePath, issues, passedRules, skippedRules };
88
+ }
89
+ /**
90
+ * Analyses multiple documents.
91
+ *
92
+ * Loads the full ruleset once, filters by plan, then runs each document
93
+ * against the filtered set in parallel.
94
+ *
95
+ * plan defaults to 'free' — the safest default (most restrictive).
96
+ * kvStore must be provided in production; in tests loadRules is mocked.
97
+ */
98
+ export async function analyseDocuments(docs, plan = 'free', kvStore, config = EMPTY_CONFIG) {
99
+ const { rules: allRules, version, updatedAt } = await loadRules(kvStore);
100
+ // Rules are un-gated — every tier runs the full ruleset (filter is a pass-through).
101
+ const filteredRules = filterRulesForPlan(allRules, plan);
102
+ // Per-repo .seocode.json overrides: drop disabled rules, reclassify severities.
103
+ const effectiveRules = applyConfigToRules(filteredRules, config);
104
+ const fileResults = await Promise.all(docs.map(doc => analyseDocument(doc, effectiveRules)));
105
+ return {
106
+ fileResults,
107
+ version,
108
+ rulesDate: updatedAt,
109
+ // Count reflects the rules actually in force for this repo (post-config).
110
+ totalRuleCount: effectiveRules.filter(r => r.enabled).length,
111
+ };
112
+ }
@@ -0,0 +1,35 @@
1
+ let cache = null;
2
+ const DEFAULT_TTL_MS = 3_600_000; // 1 hour
3
+ const KV_KEY = 'rules';
4
+ /**
5
+ * Loads the SEO rule set from Cloudflare KV.
6
+ *
7
+ * Rules are stored under the key "rules" in the SEO_RULES KV namespace.
8
+ * The value is the full seo-rules.json stringified.
9
+ *
10
+ * Results are cached in-memory for 1 hour so repeated PR events within
11
+ * the same isolate don't hit KV on every request.
12
+ *
13
+ * In tests, loadRules is mocked entirely.
14
+ */
15
+ export async function loadRules(kvStore, ttlMs = DEFAULT_TTL_MS) {
16
+ const now = Date.now();
17
+ if (cache && now - cache.fetchedAt < ttlMs) {
18
+ return { rules: cache.rules, version: cache.version, updatedAt: cache.updatedAt };
19
+ }
20
+ const raw = await kvStore.get(KV_KEY);
21
+ if (!raw) {
22
+ throw new Error('SEO rules not found in KV. Run: npx wrangler kv key put --binding=SEO_RULES "rules" --path=rules/seo-rules.json');
23
+ }
24
+ const data = JSON.parse(raw);
25
+ cache = {
26
+ rules: data.rules,
27
+ version: data.version,
28
+ updatedAt: data.updatedAt,
29
+ fetchedAt: now,
30
+ };
31
+ return { rules: cache.rules, version: cache.version, updatedAt: cache.updatedAt };
32
+ }
33
+ export function invalidateCache() {
34
+ cache = null;
35
+ }