nerima 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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 1463ee71dbe01193461cb0c748a2d58d26dfd435a950edf1d943bd9591d2c8b3
4
+ data.tar.gz: 01321c4e553823f4116aa49b840dc1769cdfc7a2007fba87b19b9a4602c153c7
5
+ SHA512:
6
+ metadata.gz: 26531a5db88201918c1c6e9b0839c9ea46416c5e4ca689cd6a59b2bfe5723389dfeac8f3a22d7b4789a0b853df398220725cf2543344c693874fdca3d2066f4f
7
+ data.tar.gz: 53b125cf78c956aada5ea5f0676915a2342ef0debd1c80a76a362e26a55afef5814124a688afbb08f187ebc076d581354c1948810218e13687bb649f7fe48023
data/exe/nerima ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "seo/cli"
5
+
6
+ Seo::CLI::App.start(ARGV)
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "result"
4
+
5
+ module Seo
6
+ class Analyser
7
+ def initialize(provider:)
8
+ @provider = provider
9
+ end
10
+
11
+ MAX_CONCURRENT = 5
12
+
13
+ def analyse(files, &on_result)
14
+ results = Array.new(files.length)
15
+ queue = Queue.new
16
+ files.each_with_index { |f, i| queue << [f, i] }
17
+ mutex = Mutex.new
18
+
19
+ [files.length, MAX_CONCURRENT].min.times.map do
20
+ Thread.new do
21
+ while (job = begin; queue.pop(true); rescue ThreadError; nil; end)
22
+ file, i = job
23
+ begin
24
+ content = File.read(file)
25
+ result = analyse_file(file, content)
26
+ rescue => e
27
+ result = Result.new(
28
+ file: file,
29
+ issues: [{ severity: :critical, check: "internal", message: "Could not read file: #{e.message}", line: nil }],
30
+ suggestions: [],
31
+ fixed_content: nil
32
+ )
33
+ end
34
+ mutex.synchronize do
35
+ results[i] = result
36
+ on_result&.call(result)
37
+ end
38
+ end
39
+ end
40
+ end.each(&:join)
41
+
42
+ results
43
+ end
44
+
45
+ # Called only by `seo fix` — separate API call, higher token budget
46
+ def fix(file)
47
+ content = File.read(file)
48
+ fixed = call_api(build_fix_prompt(file, content), max_tokens: 4096)
49
+ result = analyse_file(file, content)
50
+ Result.new(
51
+ file: result.file,
52
+ issues: result.issues,
53
+ suggestions: result.suggestions,
54
+ fixed_content: fixed.strip != content.strip ? fixed : nil
55
+ )
56
+ end
57
+
58
+ private
59
+
60
+ def analyse_file(file, content)
61
+ raw = call_api(build_prompt(file, content), max_tokens: 2048)
62
+ parse_response(file, raw)
63
+ end
64
+
65
+ def call_api(prompt, max_tokens:)
66
+ @provider.complete(prompt, max_tokens: max_tokens)
67
+ end
68
+
69
+ def build_prompt(file, content)
70
+ <<~PROMPT
71
+ You are an SEO expert analysing a web template file. Analyse the following file for SEO issues.
72
+
73
+ File: #{file}
74
+ Template type: #{detect_template_type(file)}
75
+
76
+ ```
77
+ #{content.slice(0, 8000)}
78
+ ```
79
+
80
+ Return ONLY a raw JSON object — no markdown, no code fences, no explanation before or after. Use this exact structure:
81
+ {"issues":[{"severity":"critical","check":"meta","message":"description","line":null}],"suggestions":["suggestion"],"ai_risk":"none","ai_risk_reason":"reason"}
82
+
83
+ severity: "critical" or "warning"
84
+ check: one of meta, headings, images, links, canonical, opengraph, schema, content
85
+ ai_risk: "none", "low", "medium", or "high"
86
+
87
+ Check for:
88
+ - Meta title: present, 50-60 chars, contains primary keyword
89
+ - Meta description: present, 150-160 chars, compelling
90
+ - Heading hierarchy: single H1, logical H2/H3 structure, no skipped levels
91
+ - Images: all have descriptive alt text
92
+ - Canonical tag present
93
+ - Open Graph tags: og:title, og:description, og:image
94
+ - Schema/structured data present
95
+ - Content: thin content, keyword usage, internal linking
96
+ - AI risk: is this content likely to be replaced by Google AI overviews?
97
+ PROMPT
98
+ end
99
+
100
+ def build_fix_prompt(file, content)
101
+ <<~PROMPT
102
+ You are an SEO expert. Return an improved version of the following web template file.
103
+
104
+ File: #{file}
105
+ Template type: #{detect_template_type(file)}
106
+
107
+ ```
108
+ #{content}
109
+ ```
110
+
111
+ Return ONLY the corrected file content — no explanation, no markdown fences. Improve: meta tags, alt text, heading structure, canonical tags, Open Graph tags, structured data. Do not rewrite body copy or change layout.
112
+ PROMPT
113
+ end
114
+
115
+ def detect_template_type(file)
116
+ case File.extname(file)
117
+ when ".erb" then "ERB (Ruby)"
118
+ when ".haml" then "Haml"
119
+ when ".slim" then "Slim"
120
+ when ".jinja", ".jinja2" then "Jinja2 (Python)"
121
+ when ".twig" then "Twig (PHP)"
122
+ when ".njk" then "Nunjucks (JS)"
123
+ when ".html" then "HTML"
124
+ else "Unknown"
125
+ end
126
+ end
127
+
128
+ def parse_response(file, raw)
129
+ start_idx = raw.index("{")
130
+ end_idx = raw.rindex("}")
131
+ json_str = (start_idx && end_idx) ? raw[start_idx..end_idx] : raw
132
+ data = JSON.parse(json_str)
133
+
134
+ issues = (data["issues"] || []).map do |i|
135
+ {
136
+ severity: i["severity"].to_sym,
137
+ check: i["check"],
138
+ message: i["message"],
139
+ line: i["line"],
140
+ ai_risk: data["ai_risk"],
141
+ ai_risk_reason: data["ai_risk_reason"]
142
+ }
143
+ end
144
+
145
+ Result.new(
146
+ file: file,
147
+ issues: issues,
148
+ suggestions: data["suggestions"] || [],
149
+ fixed_content: nil
150
+ )
151
+ rescue JSON::ParserError
152
+ Result.new(
153
+ file: file,
154
+ issues: [{ severity: :warning, check: "internal", message: "Could not parse AI response", line: nil }],
155
+ suggestions: [],
156
+ fixed_content: nil
157
+ )
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Seo
6
+ module CLI
7
+ module Config
8
+ CONFIG_FILE = ".seo.yml"
9
+
10
+ DEFAULTS = {
11
+ "include" => [
12
+ "**/*.html.erb",
13
+ "**/*.html.haml",
14
+ "**/*.html.slim",
15
+ "**/*.html",
16
+ "**/*.jinja",
17
+ "**/*.jinja2",
18
+ "**/*.twig",
19
+ "**/*.blade.php",
20
+ "**/*.njk"
21
+ ],
22
+ "exclude" => [
23
+ "vendor/**",
24
+ "node_modules/**",
25
+ "tmp/**",
26
+ ".git/**"
27
+ ],
28
+ "checks" => %w[meta headings images links canonical opengraph schema],
29
+ "provider" => "anthropic",
30
+ "api_key" => nil
31
+ }.freeze
32
+
33
+ def self.load
34
+ if File.exist?(CONFIG_FILE)
35
+ DEFAULTS.merge(YAML.safe_load_file(CONFIG_FILE) || {})
36
+ else
37
+ DEFAULTS
38
+ end
39
+ end
40
+
41
+ def self.write_default
42
+ File.write(CONFIG_FILE, YAML.dump(DEFAULTS))
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Seo
4
+ module Cli
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
data/lib/seo/cli.rb ADDED
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+ require "json"
5
+ require "pastel"
6
+ require "tty-prompt"
7
+
8
+ require_relative "cli/version"
9
+ require_relative "cli/config"
10
+ require_relative "scanner"
11
+ require_relative "analyser"
12
+ require_relative "providers"
13
+ require_relative "output/terminal"
14
+ require_relative "output/reporter"
15
+
16
+ module Seo
17
+ module CLI
18
+ LOGO = <<~LOGO
19
+ _ _ ___ ___ ___ __ __ _
20
+ | \\| | __| _ \\_ _| \\/ | /_\\
21
+ | .` | _|| /| || |\\/| |/ _ \\
22
+ |_|\\_|___|_|_\\___|_| |_/_/ \\_\\
23
+ LOGO
24
+
25
+ class App < Thor
26
+ def self.start(args = ARGV, **opts)
27
+ pastel = Pastel.new
28
+ $stdout.puts pastel.bright_magenta(LOGO)
29
+ $stdout.puts pastel.bright_magenta(" AI-powered SEO analysis") + "\n\n"
30
+ super
31
+ end
32
+
33
+ class_option :provider, type: :string, desc: "AI provider: anthropic, openai, gemini, ollama (overrides .seo.yml)"
34
+ class_option :key, type: :string, desc: "API key for the selected provider (overrides .seo.yml and env vars)"
35
+ class_option :model, type: :string, desc: "Model name to use (overrides provider default)"
36
+
37
+ desc "check [FILE]", "Analyse a file or entire project for SEO issues"
38
+ option :format, type: :string, default: "terminal", enum: %w[terminal json html], desc: "Output format"
39
+ def check(file = nil)
40
+ files = file ? [file] : Scanner.new(config).scan
41
+ if files.empty?
42
+ puts pastel.yellow("No template files found. Check your .seo.yml config.")
43
+ return
44
+ end
45
+
46
+ puts pastel.cyan("Analysing #{files.length} file(s)...")
47
+
48
+ if options[:format] == "terminal"
49
+ terminal = Output::Terminal.new
50
+ results = Analyser.new(provider: provider).analyse(files) { |r| terminal.render_result(r) }
51
+ terminal.render_summary(results)
52
+ else
53
+ results = Analyser.new(provider: provider).analyse(files)
54
+ puts Output::Reporter.new.render(results, format: options[:format])
55
+ end
56
+ end
57
+
58
+ desc "fix FILE", "Show a diff of suggested SEO fixes for a file and optionally apply them"
59
+ def fix(file)
60
+ abort(pastel.red("File not found: #{file}")) unless File.exist?(file)
61
+
62
+ puts pastel.cyan("Analysing #{file}...")
63
+ result = Analyser.new(provider: provider).fix(file)
64
+
65
+ unless result&.fixed_content
66
+ puts pastel.green("No fixes suggested for #{file}")
67
+ return
68
+ end
69
+
70
+ Output::Terminal.new.render_diff(result)
71
+
72
+ prompt = TTY::Prompt.new
73
+ if prompt.yes?("Apply these changes to #{file}?")
74
+ File.write(file, result.fixed_content)
75
+ puts pastel.green("✓ Changes applied to #{file}")
76
+ else
77
+ puts pastel.yellow("Changes not applied.")
78
+ end
79
+ end
80
+
81
+ desc "report", "Generate a full SEO report for the project"
82
+ option :format, type: :string, default: "terminal", enum: %w[terminal json html pdf], desc: "Output format"
83
+ option :output, type: :string, desc: "Write report to file (e.g. seo-report.html or seo-report.pdf)"
84
+ def report
85
+ files = Scanner.new(config).scan
86
+ if files.empty?
87
+ puts pastel.yellow("No template files found.")
88
+ return
89
+ end
90
+
91
+ if options[:format] == "pdf" && options[:output].nil?
92
+ abort pastel.red("--output is required for PDF format. Example: nerima report --format pdf --output seo-report.pdf")
93
+ end
94
+
95
+ puts pastel.cyan("Scanning #{files.length} files...")
96
+ done = 0
97
+ results = Analyser.new(provider: provider).analyse(files) do |r|
98
+ done += 1
99
+ puts pastel.dim(" [#{done}/#{files.length}] #{File.basename(r.file)}")
100
+ end
101
+ Output::Terminal.new.render_summary(results)
102
+
103
+ if options[:output]
104
+ if options[:format] == "pdf"
105
+ Output::Reporter.new.render_pdf(results, output_path: options[:output])
106
+ else
107
+ fmt = options[:format] == "terminal" ? "html" : options[:format]
108
+ File.write(options[:output], Output::Reporter.new.render(results, format: fmt))
109
+ end
110
+ puts pastel.green("✓ Report written to #{options[:output]}")
111
+ end
112
+ end
113
+
114
+ desc "init", "Create a .seo.yml config file in the current directory"
115
+ def init
116
+ if File.exist?(".seo.yml")
117
+ puts pastel.yellow(".seo.yml already exists.")
118
+ return
119
+ end
120
+ Seo::CLI::Config.write_default
121
+ puts pastel.green("✓ Created .seo.yml — edit it to configure which files to scan.")
122
+ end
123
+
124
+ private
125
+
126
+ def provider
127
+ provider_name = options[:provider] || config["provider"] || "anthropic"
128
+ api_key = options[:key] || env_key_for(provider_name) || config["api_key"] ||
129
+ config["anthropic_api_key"] # backward compat
130
+
131
+ if api_key.nil? && provider_name != "ollama"
132
+ abort pastel.red("No API key found for '#{provider_name}'. Set --key, the relevant env var, or add api_key to .seo.yml")
133
+ end
134
+
135
+ Providers.build(
136
+ provider_name: provider_name,
137
+ api_key: api_key,
138
+ model: options[:model] || config["model"]
139
+ )
140
+ rescue ArgumentError => e
141
+ abort pastel.red(e.message)
142
+ end
143
+
144
+ def env_key_for(provider_name)
145
+ env_var = {
146
+ "anthropic" => "ANTHROPIC_API_KEY",
147
+ "openai" => "OPENAI_API_KEY",
148
+ "gemini" => "GEMINI_API_KEY"
149
+ }[provider_name.to_s]
150
+ ENV[env_var] if env_var
151
+ end
152
+
153
+ def config
154
+ @config ||= Seo::CLI::Config.load
155
+ end
156
+
157
+ def pastel
158
+ @pastel ||= Pastel.new
159
+ end
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,231 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prawn"
4
+ require "prawn/table"
5
+
6
+ module Seo
7
+ module Output
8
+ class Reporter
9
+ INDIGO = "4f46e5"
10
+ RED = "dc2626"
11
+ AMBER = "d97706"
12
+ GREEN = "16a34a"
13
+ SLATE = "475569"
14
+ LIGHT = "f1f5f9"
15
+ WHITE = "ffffff"
16
+
17
+ def render(results, format:)
18
+ case format.to_s
19
+ when "json" then render_json(results)
20
+ when "html" then render_html(results)
21
+ else raise ArgumentError, "Unknown format: #{format}"
22
+ end
23
+ end
24
+
25
+ def render_pdf(results, output_path:)
26
+ avg_score = results.sum(&:score) / [results.length, 1].max
27
+ total_issues = results.sum { |r| r.issues.length }
28
+ clean_count = results.count { |r| r.issues.empty? }
29
+
30
+ Prawn::Document.generate(output_path, page_size: "A4", margin: [36, 40, 36, 40]) do |pdf|
31
+ # ── Header ───────────────────────────────────────────────────────
32
+ pdf.fill_color INDIGO
33
+ pdf.fill_rectangle [pdf.bounds.left, pdf.bounds.top + 36], pdf.bounds.width + 80, 70
34
+ pdf.fill_color WHITE
35
+ pdf.move_to [pdf.bounds.left, pdf.bounds.top + 20]
36
+ pdf.font_size(18) { pdf.text "SEO Report", style: :bold }
37
+ pdf.font_size(9) { pdf.text "Generated #{Time.now.strftime("%d %b %Y %H:%M")} · #{results.length} files scanned", color: "c7d2fe" }
38
+ pdf.move_down 20
39
+
40
+ # ── Summary cards ────────────────────────────────────────────────
41
+ pdf.fill_color "000000"
42
+ summary_data = [
43
+ ["Average Score", "#{avg_score}/100", score_color(avg_score)],
44
+ ["Total Issues", total_issues.to_s, total_issues.zero? ? GREEN : RED],
45
+ ["Clean Files", "#{clean_count}/#{results.length}", clean_count == results.length ? GREEN : AMBER]
46
+ ]
47
+
48
+ card_width = (pdf.bounds.width - 20) / 3.0
49
+ card_height = 52
50
+
51
+ summary_data.each_with_index do |(label, value, color), i|
52
+ x = pdf.bounds.left + i * (card_width + 10)
53
+ y = pdf.cursor
54
+
55
+ pdf.fill_color LIGHT
56
+ pdf.fill_rounded_rectangle [x, y], card_width, card_height, 4
57
+ pdf.fill_color "94a3b8"
58
+ pdf.font_size(7) { pdf.draw_text label.upcase, at: [x + 10, y - 14] }
59
+ pdf.fill_color color
60
+ pdf.font_size(20) { pdf.draw_text value, at: [x + 10, y - 38], style: :bold }
61
+ end
62
+
63
+ pdf.move_down card_height + 16
64
+
65
+ # ── File results ─────────────────────────────────────────────────
66
+ results.each do |result|
67
+ pdf.fill_color "000000"
68
+
69
+ # Card background
70
+ card_start_y = pdf.cursor
71
+ pdf.fill_color LIGHT
72
+ pdf.fill_rounded_rectangle [pdf.bounds.left, card_start_y], pdf.bounds.width, 10, 4
73
+
74
+ # File name
75
+ pdf.fill_color INDIGO
76
+ pdf.font_size(8) { pdf.font("Courier") { pdf.text result.file, style: :bold } }
77
+ pdf.move_down 4
78
+
79
+ if result.issues.empty?
80
+ pdf.fill_color GREEN
81
+ pdf.font_size(9) { pdf.text "✓ No issues found" }
82
+ else
83
+ result.issues.each do |issue|
84
+ sev_color = issue[:severity] == :critical ? RED : AMBER
85
+ pdf.fill_color sev_color
86
+ pdf.font_size(7.5) do
87
+ pdf.text "[#{issue[:severity].to_s.upcase}] [#{issue[:check]}] #{issue[:message]}", indent_paragraphs: 8
88
+ end
89
+
90
+ if issue[:ai_risk] && issue[:ai_risk] != "none"
91
+ risk_color = { "high" => RED, "medium" => AMBER, "low" => GREEN }.fetch(issue[:ai_risk], SLATE)
92
+ pdf.fill_color risk_color
93
+ pdf.font_size(7) { pdf.text " AI Overview risk: #{issue[:ai_risk].upcase}", indent_paragraphs: 16 }
94
+ end
95
+ end
96
+
97
+ unless result.suggestions.empty?
98
+ pdf.move_down 3
99
+ pdf.fill_color SLATE
100
+ result.suggestions.each do |s|
101
+ pdf.font_size(7.5) { pdf.text "• #{s}", indent_paragraphs: 8 }
102
+ end
103
+ end
104
+ end
105
+
106
+ # Score
107
+ pdf.move_down 4
108
+ pdf.fill_color "94a3b8"
109
+ pdf.font_size(7) { pdf.text "Score: #{result.score}/100", align: :right, color: score_color(result.score) }
110
+ pdf.move_down 10
111
+
112
+ # Stretch card bg to actual height
113
+ card_height_actual = card_start_y - pdf.cursor
114
+ pdf.fill_color LIGHT
115
+ pdf.fill_rounded_rectangle [pdf.bounds.left, card_start_y], pdf.bounds.width, card_height_actual, 4
116
+ pdf.move_down 8
117
+
118
+ pdf.start_new_page if pdf.cursor < 80
119
+ end
120
+ end
121
+ end
122
+
123
+ private
124
+
125
+ def render_json(results)
126
+ JSON.pretty_generate(results.map(&:to_h))
127
+ end
128
+
129
+ def render_html(results)
130
+ avg_score = results.sum(&:score) / [results.length, 1].max
131
+ total_issues = results.sum { |r| r.issues.length }
132
+
133
+ <<~HTML
134
+ <!DOCTYPE html>
135
+ <html lang="en">
136
+ <head>
137
+ <meta charset="UTF-8">
138
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
139
+ <title>SEO Report</title>
140
+ <style>
141
+ * { box-sizing: border-box; margin: 0; padding: 0; }
142
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #f8fafc; color: #1e293b; }
143
+ .header { background: #4f46e5; color: white; padding: 2rem; }
144
+ .header h1 { font-size: 1.5rem; font-weight: 700; }
145
+ .header p { opacity: 0.8; margin-top: 0.25rem; font-size: 0.875rem; }
146
+ .summary { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; padding: 1.5rem; }
147
+ .card { background: white; border-radius: 0.75rem; padding: 1.25rem; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
148
+ .card .label { font-size: 0.75rem; text-transform: uppercase; color: #94a3b8; font-weight: 600; }
149
+ .card .value { font-size: 2rem; font-weight: 700; margin-top: 0.25rem; }
150
+ .score-high { color: #16a34a; }
151
+ .score-mid { color: #d97706; }
152
+ .score-low { color: #dc2626; }
153
+ .files { padding: 0 1.5rem 1.5rem; display: grid; gap: 1rem; }
154
+ .file-card { background: white; border-radius: 0.75rem; padding: 1.25rem; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
155
+ .file-name { font-weight: 600; font-family: monospace; font-size: 0.875rem; margin-bottom: 0.75rem; }
156
+ .issue { display: flex; gap: 0.5rem; align-items: flex-start; padding: 0.4rem 0; font-size: 0.875rem; border-bottom: 1px solid #f1f5f9; }
157
+ .issue:last-child { border-bottom: none; }
158
+ .badge { font-size: 0.65rem; font-weight: 700; padding: 0.15rem 0.4rem; border-radius: 9999px; white-space: nowrap; }
159
+ .critical { background: #fee2e2; color: #dc2626; }
160
+ .warning { background: #fef3c7; color: #d97706; }
161
+ .check-badge { background: #e0e7ff; color: #4f46e5; }
162
+ .suggestions { margin-top: 0.75rem; padding-top: 0.75rem; border-top: 1px solid #f1f5f9; }
163
+ .suggestions li { font-size: 0.8125rem; color: #475569; padding: 0.2rem 0 0.2rem 1rem; list-style: disc; }
164
+ .ai-risk { display: inline-flex; align-items: center; gap: 0.4rem; font-size: 0.75rem; font-weight: 600; margin-top: 0.5rem; }
165
+ .risk-high { color: #dc2626; }
166
+ .risk-medium { color: #d97706; }
167
+ .risk-low { color: #16a34a; }
168
+ .clean { color: #16a34a; font-size: 0.875rem; }
169
+ </style>
170
+ </head>
171
+ <body>
172
+ <div class="header">
173
+ <h1>SEO Report</h1>
174
+ <p>Generated #{Time.now.strftime("%d %b %Y %H:%M")} · #{results.length} files scanned</p>
175
+ </div>
176
+
177
+ <div class="summary">
178
+ <div class="card">
179
+ <div class="label">Average Score</div>
180
+ <div class="value #{score_class(avg_score)}">#{avg_score}/100</div>
181
+ </div>
182
+ <div class="card">
183
+ <div class="label">Total Issues</div>
184
+ <div class="value #{total_issues.zero? ? "score-high" : "score-low"}">#{total_issues}</div>
185
+ </div>
186
+ <div class="card">
187
+ <div class="label">Files Scanned</div>
188
+ <div class="value">#{results.length}</div>
189
+ </div>
190
+ </div>
191
+
192
+ <div class="files">
193
+ #{results.map { |r| file_card(r) }.join("\n")}
194
+ </div>
195
+ </body>
196
+ </html>
197
+ HTML
198
+ end
199
+
200
+ def file_card(result)
201
+ issues_html = if result.issues.empty?
202
+ '<p class="clean">✓ No issues found</p>'
203
+ else
204
+ result.issues.map do |i|
205
+ ai_note = ""
206
+ if i[:ai_risk] && i[:ai_risk] != "none"
207
+ ai_note = "<div class=\"ai-risk risk-#{i[:ai_risk]}\">AI Overview risk: #{i[:ai_risk].upcase}</div>"
208
+ end
209
+ "<div class=\"issue\"><span class=\"badge #{i[:severity]}\">#{i[:severity]}</span><span class=\"badge check-badge\">#{i[:check]}</span><span>#{i[:message]}</span>#{ai_note}</div>"
210
+ end.join
211
+ end
212
+
213
+ suggestions_html = unless result.suggestions.empty?
214
+ "<div class=\"suggestions\"><ul>#{result.suggestions.map { |s| "<li>#{s}</li>" }.join}</ul></div>"
215
+ end
216
+
217
+ score_html = "<div style='text-align:right;font-size:0.75rem;color:#94a3b8;margin-top:0.5rem;'>Score: <strong class='#{score_class(result.score)}'>#{result.score}/100</strong></div>"
218
+
219
+ "<div class=\"file-card\"><div class=\"file-name\">#{result.file}</div>#{issues_html}#{suggestions_html}#{score_html}</div>"
220
+ end
221
+
222
+ def score_class(score)
223
+ score >= 80 ? "score-high" : score >= 50 ? "score-mid" : "score-low"
224
+ end
225
+
226
+ def score_color(score)
227
+ score >= 80 ? GREEN : score >= 50 ? AMBER : RED
228
+ end
229
+ end
230
+ end
231
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pastel"
4
+ require "diffy"
5
+
6
+ module Seo
7
+ module Output
8
+ class Terminal
9
+ def initialize
10
+ @pastel = Pastel.new
11
+ end
12
+
13
+ def render(results)
14
+ results.each { |r| render_result(r) }
15
+ render_summary(results)
16
+ end
17
+
18
+ def render_result(result)
19
+ puts ""
20
+ puts @pastel.bold(result.file)
21
+ puts @pastel.dim("─" * 60)
22
+
23
+ if result.issues.empty?
24
+ puts @pastel.green(" ✓ No issues found")
25
+ else
26
+ result.critical.each do |issue|
27
+ line_ref = issue[:line] ? @pastel.dim(" (line #{issue[:line]})") : ""
28
+ puts " #{@pastel.red("✖")} #{@pastel.bold("[#{issue[:check]}]")} #{issue[:message]}#{line_ref}"
29
+ end
30
+ result.warnings.each do |issue|
31
+ line_ref = issue[:line] ? @pastel.dim(" (line #{issue[:line]})") : ""
32
+ puts " #{@pastel.yellow("⚠")} #{@pastel.bold("[#{issue[:check]}]")} #{issue[:message]}#{line_ref}"
33
+ end
34
+ end
35
+
36
+ if result.issues.any? && result.issues.first[:ai_risk] && result.issues.first[:ai_risk] != "none"
37
+ risk = result.issues.first[:ai_risk]
38
+ colour = { "low" => :green, "medium" => :yellow, "high" => :red }.fetch(risk, :white)
39
+ puts ""
40
+ puts " #{@pastel.bold("AI Overview risk:")} #{@pastel.send(colour, risk.upcase)}"
41
+ puts @pastel.dim(" #{result.issues.first[:ai_risk_reason]}")
42
+ end
43
+
44
+ unless result.suggestions.empty?
45
+ puts ""
46
+ puts @pastel.cyan(" Suggestions:")
47
+ result.suggestions.each { |s| puts " • #{s}" }
48
+ end
49
+
50
+ puts " #{@pastel.dim("Score:")} #{score_colour(result.score)}"
51
+ end
52
+
53
+ def render_diff(result)
54
+ puts ""
55
+ puts @pastel.bold("Suggested changes for #{result.file}:")
56
+ puts ""
57
+
58
+ diff = Diffy::Diff.new(
59
+ File.read(result.file),
60
+ result.fixed_content,
61
+ context: 3
62
+ )
63
+
64
+ diff.each_chunk do |chunk|
65
+ chunk.each_line do |line|
66
+ case line[0]
67
+ when "+"
68
+ print @pastel.green(line)
69
+ when "-"
70
+ print @pastel.red(line)
71
+ else
72
+ print @pastel.dim(line)
73
+ end
74
+ end
75
+ end
76
+ puts ""
77
+ end
78
+
79
+ def render_summary(results)
80
+ total = results.length
81
+ clean = results.count { |r| r.issues.empty? }
82
+ issues = results.sum { |r| r.issues.length }
83
+ avg = results.sum(&:score) / [total, 1].max
84
+
85
+ puts ""
86
+ puts @pastel.bold("─" * 60)
87
+ puts @pastel.bold("Summary")
88
+ puts " Files scanned: #{total}"
89
+ puts " Clean files: #{@pastel.green(clean.to_s)}"
90
+ puts " Total issues: #{issues.positive? ? @pastel.red(issues.to_s) : @pastel.green("0")}"
91
+ puts " Average score: #{score_colour(avg)}"
92
+
93
+ high_risk = results.select { |r| r.issues.any? { |i| i[:ai_risk] == "high" } }
94
+ unless high_risk.empty?
95
+ puts ""
96
+ puts @pastel.red(" High AI Overview risk (#{high_risk.length} files):")
97
+ high_risk.each { |r| puts " • #{r.file}" }
98
+ end
99
+ puts ""
100
+ end
101
+
102
+ private
103
+
104
+ def score_colour(score)
105
+ colour = score >= 80 ? :green : score >= 50 ? :yellow : :red
106
+ @pastel.send(colour, "#{score}/100")
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "anthropic"
4
+ require_relative "base"
5
+
6
+ module Seo
7
+ module Providers
8
+ class Anthropic < Base
9
+ DEFAULT_MODEL = "claude-sonnet-4-6"
10
+
11
+ def initialize(api_key:, model: DEFAULT_MODEL)
12
+ @client = ::Anthropic::Client.new(access_token: api_key)
13
+ @model = model
14
+ end
15
+
16
+ def complete(prompt, max_tokens:)
17
+ response = @client.messages(parameters: {
18
+ model: @model,
19
+ max_tokens: max_tokens,
20
+ messages: [{ role: "user", content: prompt }]
21
+ })
22
+ response.dig("content", 0, "text").to_s
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Seo
4
+ module Providers
5
+ class Base
6
+ def complete(prompt, max_tokens:)
7
+ raise NotImplementedError, "#{self.class} must implement #complete"
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "uri"
6
+ require_relative "base"
7
+
8
+ module Seo
9
+ module Providers
10
+ class Gemini < Base
11
+ DEFAULT_MODEL = "gemini-1.5-pro"
12
+ BASE_URL = "https://generativelanguage.googleapis.com/v1beta/models"
13
+
14
+ def initialize(api_key:, model: DEFAULT_MODEL)
15
+ @api_key = api_key
16
+ @model = model
17
+ end
18
+
19
+ def complete(prompt, max_tokens:)
20
+ uri = URI("#{BASE_URL}/#{@model}:generateContent?key=#{@api_key}")
21
+ http = Net::HTTP.new(uri.host, uri.port)
22
+ http.use_ssl = true
23
+
24
+ req = Net::HTTP::Post.new(uri)
25
+ req["Content-Type"] = "application/json"
26
+ req.body = JSON.generate({
27
+ contents: [{ parts: [{ text: prompt }] }],
28
+ generationConfig: { maxOutputTokens: max_tokens }
29
+ })
30
+
31
+ data = JSON.parse(http.request(req).body)
32
+ data.dig("candidates", 0, "content", "parts", 0, "text").to_s
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "uri"
6
+ require_relative "base"
7
+
8
+ module Seo
9
+ module Providers
10
+ class Ollama < Base
11
+ DEFAULT_MODEL = "llama3"
12
+ DEFAULT_HOST = "http://localhost:11434"
13
+
14
+ def initialize(api_key: nil, model: DEFAULT_MODEL, host: DEFAULT_HOST)
15
+ @model = model
16
+ @host = host
17
+ end
18
+
19
+ def complete(prompt, max_tokens:)
20
+ uri = URI("#{@host}/api/chat")
21
+ http = Net::HTTP.new(uri.host, uri.port)
22
+ http.use_ssl = uri.scheme == "https"
23
+
24
+ req = Net::HTTP::Post.new(uri)
25
+ req["Content-Type"] = "application/json"
26
+ req.body = JSON.generate({
27
+ model: @model,
28
+ stream: false,
29
+ messages: [{ role: "user", content: prompt }]
30
+ })
31
+
32
+ data = JSON.parse(http.request(req).body)
33
+ data.dig("message", "content").to_s
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "uri"
6
+ require_relative "base"
7
+
8
+ module Seo
9
+ module Providers
10
+ class OpenAI < Base
11
+ DEFAULT_MODEL = "gpt-4o"
12
+ API_URL = "https://api.openai.com/v1/chat/completions"
13
+
14
+ def initialize(api_key:, model: DEFAULT_MODEL)
15
+ @api_key = api_key
16
+ @model = model
17
+ end
18
+
19
+ def complete(prompt, max_tokens:)
20
+ uri = URI(API_URL)
21
+ http = Net::HTTP.new(uri.host, uri.port)
22
+ http.use_ssl = true
23
+
24
+ req = Net::HTTP::Post.new(uri)
25
+ req["Authorization"] = "Bearer #{@api_key}"
26
+ req["Content-Type"] = "application/json"
27
+ req.body = JSON.generate({
28
+ model: @model,
29
+ max_tokens: max_tokens,
30
+ messages: [{ role: "user", content: prompt }]
31
+ })
32
+
33
+ data = JSON.parse(http.request(req).body)
34
+ data.dig("choices", 0, "message", "content").to_s
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "providers/base"
4
+ require_relative "providers/anthropic"
5
+ require_relative "providers/openai"
6
+ require_relative "providers/gemini"
7
+ require_relative "providers/ollama"
8
+
9
+ module Seo
10
+ module Providers
11
+ REGISTRY = {
12
+ "anthropic" => Anthropic,
13
+ "openai" => OpenAI,
14
+ "gemini" => Gemini,
15
+ "ollama" => Ollama
16
+ }.freeze
17
+
18
+ def self.build(provider_name:, api_key: nil, model: nil, host: nil)
19
+ klass = REGISTRY[provider_name.to_s.downcase]
20
+ raise ArgumentError, "Unknown provider '#{provider_name}'. Supported: #{REGISTRY.keys.join(", ")}" unless klass
21
+
22
+ args = { api_key: api_key }
23
+ args[:model] = model if model
24
+ args[:host] = host if host && provider_name.to_s == "ollama"
25
+ klass.new(**args)
26
+ end
27
+
28
+ def self.names
29
+ REGISTRY.keys
30
+ end
31
+ end
32
+ end
data/lib/seo/result.rb ADDED
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Seo
4
+ Result = Struct.new(:file, :issues, :suggestions, :fixed_content, keyword_init: true) do
5
+ def score
6
+ return 100 if issues.empty?
7
+ [100 - (issues.length * 10), 0].max
8
+ end
9
+
10
+ def critical
11
+ issues.select { |i| i[:severity] == :critical }
12
+ end
13
+
14
+ def warnings
15
+ issues.select { |i| i[:severity] == :warning }
16
+ end
17
+
18
+ def to_h
19
+ {
20
+ file: file,
21
+ score: score,
22
+ issues: issues,
23
+ suggestions: suggestions
24
+ }
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module Seo
6
+ class Scanner
7
+ def initialize(config)
8
+ @include_patterns = config["include"] || []
9
+ @exclude_patterns = config["exclude"] || []
10
+ end
11
+
12
+ def scan(root = Dir.pwd)
13
+ files = @include_patterns.flat_map do |pattern|
14
+ Dir.glob(File.join(root, pattern))
15
+ end.uniq.sort
16
+
17
+ files.reject do |file|
18
+ @exclude_patterns.any? do |pattern|
19
+ File.fnmatch?(File.join(root, pattern), file, File::FNM_PATHNAME)
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
metadata ADDED
@@ -0,0 +1,195 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: nerima
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Tom Dringer
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 2026-05-30 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: thor
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.3'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.3'
26
+ - !ruby/object:Gem::Dependency
27
+ name: ruby-anthropic
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '0.4'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '0.4'
40
+ - !ruby/object:Gem::Dependency
41
+ name: nokogiri
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '1.16'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '1.16'
54
+ - !ruby/object:Gem::Dependency
55
+ name: diffy
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '3.4'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '3.4'
68
+ - !ruby/object:Gem::Dependency
69
+ name: tty-prompt
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '0.23'
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '0.23'
82
+ - !ruby/object:Gem::Dependency
83
+ name: tty-color
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '0.6'
89
+ type: :runtime
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '0.6'
96
+ - !ruby/object:Gem::Dependency
97
+ name: pastel
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - "~>"
101
+ - !ruby/object:Gem::Version
102
+ version: '0.8'
103
+ type: :runtime
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - "~>"
108
+ - !ruby/object:Gem::Version
109
+ version: '0.8'
110
+ - !ruby/object:Gem::Dependency
111
+ name: json
112
+ requirement: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - "~>"
115
+ - !ruby/object:Gem::Version
116
+ version: '2.7'
117
+ type: :runtime
118
+ prerelease: false
119
+ version_requirements: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - "~>"
122
+ - !ruby/object:Gem::Version
123
+ version: '2.7'
124
+ - !ruby/object:Gem::Dependency
125
+ name: prawn
126
+ requirement: !ruby/object:Gem::Requirement
127
+ requirements:
128
+ - - "~>"
129
+ - !ruby/object:Gem::Version
130
+ version: '2.5'
131
+ type: :runtime
132
+ prerelease: false
133
+ version_requirements: !ruby/object:Gem::Requirement
134
+ requirements:
135
+ - - "~>"
136
+ - !ruby/object:Gem::Version
137
+ version: '2.5'
138
+ - !ruby/object:Gem::Dependency
139
+ name: prawn-table
140
+ requirement: !ruby/object:Gem::Requirement
141
+ requirements:
142
+ - - "~>"
143
+ - !ruby/object:Gem::Version
144
+ version: '0.2'
145
+ type: :runtime
146
+ prerelease: false
147
+ version_requirements: !ruby/object:Gem::Requirement
148
+ requirements:
149
+ - - "~>"
150
+ - !ruby/object:Gem::Version
151
+ version: '0.2'
152
+ description: AI-powered SEO analysis for developers. Scan your templates, spot issues,
153
+ and fix them — without ever leaving your terminal.
154
+ executables:
155
+ - nerima
156
+ extensions: []
157
+ extra_rdoc_files: []
158
+ files:
159
+ - exe/nerima
160
+ - lib/seo/analyser.rb
161
+ - lib/seo/cli.rb
162
+ - lib/seo/cli/config.rb
163
+ - lib/seo/cli/version.rb
164
+ - lib/seo/output/reporter.rb
165
+ - lib/seo/output/terminal.rb
166
+ - lib/seo/providers.rb
167
+ - lib/seo/providers/anthropic.rb
168
+ - lib/seo/providers/base.rb
169
+ - lib/seo/providers/gemini.rb
170
+ - lib/seo/providers/ollama.rb
171
+ - lib/seo/providers/openai.rb
172
+ - lib/seo/result.rb
173
+ - lib/seo/scanner.rb
174
+ homepage: https://nerimasoft.co.uk/nerima.html
175
+ licenses:
176
+ - MIT
177
+ metadata: {}
178
+ rdoc_options: []
179
+ require_paths:
180
+ - lib
181
+ required_ruby_version: !ruby/object:Gem::Requirement
182
+ requirements:
183
+ - - ">="
184
+ - !ruby/object:Gem::Version
185
+ version: 3.1.0
186
+ required_rubygems_version: !ruby/object:Gem::Requirement
187
+ requirements:
188
+ - - ">="
189
+ - !ruby/object:Gem::Version
190
+ version: '0'
191
+ requirements: []
192
+ rubygems_version: 3.6.2
193
+ specification_version: 4
194
+ summary: AI-powered SEO analysis for developers
195
+ test_files: []