@softspark/ai-toolkit 2.4.1 → 2.5.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.
- package/AGENTS.md +32 -19
- package/CHANGELOG.md +23 -0
- package/README.md +11 -11
- package/app/.claude-plugin/plugin.json +1 -1
- package/app/agents/code-reviewer.md +6 -7
- package/app/agents/frontend-specialist.md +33 -2
- package/app/agents/seo-specialist.md +1 -1
- package/app/personas/frontend-lead.md +48 -5
- package/app/skills/a11y-validate/SKILL.md +377 -0
- package/app/skills/a11y-validate/reference/aria-patterns.md +259 -0
- package/app/skills/a11y-validate/reference/eaa-compliance.md +252 -0
- package/app/skills/a11y-validate/reference/mobile-eaa.md +329 -0
- package/app/skills/a11y-validate/reference/wcag-2-1-aa.md +285 -0
- package/app/skills/a11y-validate/reference/wcag-2-2-aa.md +221 -0
- package/app/skills/a11y-validate/scripts/a11y-scanner.py +639 -0
- package/app/skills/clean-code/reference/python.md +3 -3
- package/app/skills/design-engineering/SKILL.md +2 -5
- package/app/skills/review/SKILL.md +30 -6
- package/app/skills/seo-validate/SKILL.md +460 -0
- package/app/skills/seo-validate/reference/core-web-vitals.md +445 -0
- package/app/skills/seo-validate/reference/geo-aeo-patterns.md +259 -0
- package/app/skills/seo-validate/reference/geo-guidelines.md +248 -0
- package/app/skills/seo-validate/reference/schema-types.md +465 -0
- package/app/skills/seo-validate/reference/spa-ssg-patterns.md +351 -0
- package/app/skills/seo-validate/reference/w3c-guidelines.md +289 -0
- package/app/skills/seo-validate/scripts/seo-scanner.py +549 -0
- package/bin/ai-toolkit.js +4 -4
- package/kb/reference/architecture-overview.md +1 -1
- package/kb/reference/comparison.md +1 -1
- package/kb/reference/skills-catalog.md +3 -1
- package/llms-full.txt +10 -6
- package/manifest.json +3 -3
- package/package.json +2 -2
- package/scripts/config_cli.py +4 -10
- package/scripts/doctor.py +3 -3
- package/scripts/install_steps/ai_tools.py +1 -1
- package/scripts/plugin.py +1 -1
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""SEO scanner -- pattern-matching heuristics for common SEO issues.
|
|
3
|
+
|
|
4
|
+
Stdlib only. No external dependencies.
|
|
5
|
+
Scans HTML/JSX/TSX/Vue/Astro/Svelte files for SEO problems across 9 categories.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
# ---------------------------------------------------------------------------
|
|
16
|
+
# Constants
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
SCAN_EXTENSIONS = {
|
|
20
|
+
".html", ".htm", ".jsx", ".tsx", ".vue", ".astro", ".svelte",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
SKIP_DIRS = {
|
|
24
|
+
"node_modules", "vendor", ".git", "dist", "build", "out", ".next",
|
|
25
|
+
".nuxt", ".svelte-kit", "__pycache__", ".venv", "venv", ".tox",
|
|
26
|
+
"public/build", "coverage", ".turbo", ".vercel",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
SKIP_FILES = {
|
|
30
|
+
"package-lock.json", "yarn.lock", "pnpm-lock.yaml",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
# File collection
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def collect_files(scan_path: Path) -> list[Path]:
|
|
39
|
+
"""Collect scannable files under the given path, respecting skip rules."""
|
|
40
|
+
files: list[Path] = []
|
|
41
|
+
for dirpath, dirnames, filenames in os.walk(scan_path):
|
|
42
|
+
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
|
|
43
|
+
for fname in filenames:
|
|
44
|
+
fpath = Path(dirpath) / fname
|
|
45
|
+
if fpath.suffix.lower() in SCAN_EXTENSIONS and fname not in SKIP_FILES:
|
|
46
|
+
files.append(fpath)
|
|
47
|
+
return sorted(files)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def find_project_root(start: Path) -> Path:
|
|
51
|
+
"""Walk up to find .git or package.json as project root indicator."""
|
|
52
|
+
current = start if start.is_dir() else start.parent
|
|
53
|
+
while current != current.parent:
|
|
54
|
+
if (current / ".git").exists() or (current / "package.json").exists():
|
|
55
|
+
return current
|
|
56
|
+
current = current.parent
|
|
57
|
+
return start if start.is_dir() else start.parent
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
# Finding accumulator
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
Findings = list[dict]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def add_finding(
|
|
68
|
+
findings: Findings,
|
|
69
|
+
severity: str,
|
|
70
|
+
category: str,
|
|
71
|
+
file_path: str,
|
|
72
|
+
line: int,
|
|
73
|
+
message: str,
|
|
74
|
+
) -> None:
|
|
75
|
+
"""Append a finding to the list."""
|
|
76
|
+
findings.append({
|
|
77
|
+
"severity": severity,
|
|
78
|
+
"category": category,
|
|
79
|
+
"file": file_path,
|
|
80
|
+
"line": line,
|
|
81
|
+
"message": message,
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
# Category 1: Meta tags
|
|
87
|
+
# ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
_RE_TITLE = re.compile(r"<title[\s>]", re.IGNORECASE)
|
|
90
|
+
_RE_META_DESC = re.compile(
|
|
91
|
+
r"""<meta\s[^>]*name\s*=\s*["']description["']""", re.IGNORECASE,
|
|
92
|
+
)
|
|
93
|
+
_RE_OG_TITLE = re.compile(
|
|
94
|
+
r"""<meta\s[^>]*property\s*=\s*["']og:title["']""", re.IGNORECASE,
|
|
95
|
+
)
|
|
96
|
+
_RE_OG_DESC = re.compile(
|
|
97
|
+
r"""<meta\s[^>]*property\s*=\s*["']og:description["']""", re.IGNORECASE,
|
|
98
|
+
)
|
|
99
|
+
_RE_OG_IMAGE = re.compile(
|
|
100
|
+
r"""<meta\s[^>]*property\s*=\s*["']og:image["']""", re.IGNORECASE,
|
|
101
|
+
)
|
|
102
|
+
_RE_CANONICAL = re.compile(
|
|
103
|
+
r"""<link\s[^>]*rel\s*=\s*["']canonical["']""", re.IGNORECASE,
|
|
104
|
+
)
|
|
105
|
+
# Framework metadata exports (Next.js App Router)
|
|
106
|
+
_RE_METADATA_EXPORT = re.compile(r"export\s+(const\s+metadata|async\s+function\s+generateMetadata)")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def check_meta_tags(content: str, rel_path: str, findings: Findings) -> None:
|
|
110
|
+
"""Check for missing meta tags in a file."""
|
|
111
|
+
has_head = re.search(r"<head[\s>]|<Head[\s>]", content) is not None
|
|
112
|
+
has_metadata_export = _RE_METADATA_EXPORT.search(content) is not None
|
|
113
|
+
|
|
114
|
+
# Only check files that define a head section or are page-level components
|
|
115
|
+
is_page = (
|
|
116
|
+
has_head
|
|
117
|
+
or has_metadata_export
|
|
118
|
+
or "layout" in rel_path.lower()
|
|
119
|
+
or "/page." in rel_path.lower()
|
|
120
|
+
or "/index." in rel_path.lower()
|
|
121
|
+
or rel_path.lower().endswith("index.html")
|
|
122
|
+
)
|
|
123
|
+
if not is_page:
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
# Skip if using framework metadata API
|
|
127
|
+
if has_metadata_export:
|
|
128
|
+
return
|
|
129
|
+
|
|
130
|
+
checks = [
|
|
131
|
+
(_RE_TITLE, "HIGH", "Missing <title> tag"),
|
|
132
|
+
(_RE_META_DESC, "HIGH", "Missing meta description"),
|
|
133
|
+
(_RE_CANONICAL, "HIGH", "Missing canonical link"),
|
|
134
|
+
(_RE_OG_TITLE, "WARN", "Missing og:title meta tag"),
|
|
135
|
+
(_RE_OG_DESC, "WARN", "Missing og:description meta tag"),
|
|
136
|
+
(_RE_OG_IMAGE, "WARN", "Missing og:image meta tag"),
|
|
137
|
+
]
|
|
138
|
+
for pattern, severity, message in checks:
|
|
139
|
+
if not pattern.search(content):
|
|
140
|
+
add_finding(findings, severity, "meta", rel_path, 1, message)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# ---------------------------------------------------------------------------
|
|
144
|
+
# Category 2: Heading hierarchy
|
|
145
|
+
# ---------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
_RE_HEADING = re.compile(r"<[hH]([1-6])[\s>]")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def check_headings(content: str, rel_path: str, findings: Findings) -> None:
|
|
151
|
+
"""Check for multiple H1 tags and skipped heading levels."""
|
|
152
|
+
h1_lines: list[int] = []
|
|
153
|
+
heading_levels: list[tuple[int, int]] = [] # (level, line_number)
|
|
154
|
+
|
|
155
|
+
for line_num, line in enumerate(content.splitlines(), 1):
|
|
156
|
+
for match in _RE_HEADING.finditer(line):
|
|
157
|
+
level = int(match.group(1))
|
|
158
|
+
heading_levels.append((level, line_num))
|
|
159
|
+
if level == 1:
|
|
160
|
+
h1_lines.append(line_num)
|
|
161
|
+
|
|
162
|
+
if len(h1_lines) > 1:
|
|
163
|
+
for line_num in h1_lines[1:]:
|
|
164
|
+
add_finding(
|
|
165
|
+
findings, "WARN", "headings", rel_path, line_num,
|
|
166
|
+
f"Multiple <h1> tags found (also at line {h1_lines[0]})",
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
# Check for skipped levels
|
|
170
|
+
for i in range(1, len(heading_levels)):
|
|
171
|
+
prev_level, _ = heading_levels[i - 1]
|
|
172
|
+
curr_level, curr_line = heading_levels[i]
|
|
173
|
+
if curr_level > prev_level + 1:
|
|
174
|
+
add_finding(
|
|
175
|
+
findings, "WARN", "headings", rel_path, curr_line,
|
|
176
|
+
f"Heading level skipped: h{prev_level} -> h{curr_level}",
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# ---------------------------------------------------------------------------
|
|
181
|
+
# Category 3: Image alt text
|
|
182
|
+
# ---------------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
_RE_IMG_TAG = re.compile(r"<(?:img|Image)\b([^>]*)>", re.IGNORECASE | re.DOTALL)
|
|
185
|
+
_RE_ALT_ATTR = re.compile(r"""\balt\s*=\s*["'{]""", re.IGNORECASE)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def check_image_alt(content: str, rel_path: str, findings: Findings) -> None:
|
|
189
|
+
"""Check for images missing alt attributes."""
|
|
190
|
+
for line_num, line in enumerate(content.splitlines(), 1):
|
|
191
|
+
for match in re.finditer(r"<(?:img|Image)\b([^>]*)/?/?>", line, re.IGNORECASE):
|
|
192
|
+
attrs = match.group(1)
|
|
193
|
+
if not _RE_ALT_ATTR.search(attrs):
|
|
194
|
+
add_finding(
|
|
195
|
+
findings, "WARN", "images", rel_path, line_num,
|
|
196
|
+
"Image missing alt attribute",
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# ---------------------------------------------------------------------------
|
|
201
|
+
# Category 4: Structured data (JSON-LD)
|
|
202
|
+
# ---------------------------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
_RE_JSON_LD = re.compile(
|
|
205
|
+
r"""<script\s[^>]*type\s*=\s*["']application/ld\+json["']""", re.IGNORECASE,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def check_structured_data(content: str, rel_path: str, findings: Findings) -> None:
|
|
210
|
+
"""Check for presence of JSON-LD structured data."""
|
|
211
|
+
is_page = (
|
|
212
|
+
"layout" in rel_path.lower()
|
|
213
|
+
or "/page." in rel_path.lower()
|
|
214
|
+
or "/index." in rel_path.lower()
|
|
215
|
+
or rel_path.lower().endswith("index.html")
|
|
216
|
+
)
|
|
217
|
+
if not is_page:
|
|
218
|
+
return
|
|
219
|
+
|
|
220
|
+
if not _RE_JSON_LD.search(content):
|
|
221
|
+
add_finding(
|
|
222
|
+
findings, "INFO", "structured-data", rel_path, 1,
|
|
223
|
+
"No JSON-LD structured data found",
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# ---------------------------------------------------------------------------
|
|
228
|
+
# Category 5: Hreflang
|
|
229
|
+
# ---------------------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
_RE_HREFLANG = re.compile(r"""hreflang\s*=\s*["'][^"']*["']""", re.IGNORECASE)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def check_hreflang(project_root: Path, findings: Findings) -> None:
|
|
235
|
+
"""Check for hreflang tags if i18n directories exist."""
|
|
236
|
+
i18n_indicators = [
|
|
237
|
+
"locales", "i18n", "translations", "lang", "messages",
|
|
238
|
+
]
|
|
239
|
+
has_i18n = False
|
|
240
|
+
for indicator in i18n_indicators:
|
|
241
|
+
if (project_root / indicator).is_dir():
|
|
242
|
+
has_i18n = True
|
|
243
|
+
break
|
|
244
|
+
|
|
245
|
+
# Also check for next-i18next, nuxt i18n modules, etc.
|
|
246
|
+
pkg_path = project_root / "package.json"
|
|
247
|
+
if pkg_path.exists():
|
|
248
|
+
try:
|
|
249
|
+
pkg = json.loads(pkg_path.read_text(errors="replace"))
|
|
250
|
+
all_deps = {}
|
|
251
|
+
all_deps.update(pkg.get("dependencies", {}))
|
|
252
|
+
all_deps.update(pkg.get("devDependencies", {}))
|
|
253
|
+
i18n_deps = ["next-i18next", "@nuxtjs/i18n", "i18next", "vue-i18n", "react-intl"]
|
|
254
|
+
for dep in i18n_deps:
|
|
255
|
+
if dep in all_deps:
|
|
256
|
+
has_i18n = True
|
|
257
|
+
break
|
|
258
|
+
except (json.JSONDecodeError, OSError):
|
|
259
|
+
pass
|
|
260
|
+
|
|
261
|
+
if not has_i18n:
|
|
262
|
+
return
|
|
263
|
+
|
|
264
|
+
# If i18n is detected, check for hreflang in HTML files
|
|
265
|
+
found_hreflang = False
|
|
266
|
+
for dirpath, dirnames, filenames in os.walk(project_root):
|
|
267
|
+
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
|
|
268
|
+
for fname in filenames:
|
|
269
|
+
fpath = Path(dirpath) / fname
|
|
270
|
+
if fpath.suffix.lower() not in SCAN_EXTENSIONS:
|
|
271
|
+
continue
|
|
272
|
+
try:
|
|
273
|
+
content = fpath.read_text(errors="replace")
|
|
274
|
+
except (OSError, PermissionError):
|
|
275
|
+
continue
|
|
276
|
+
if _RE_HREFLANG.search(content):
|
|
277
|
+
found_hreflang = True
|
|
278
|
+
break
|
|
279
|
+
if found_hreflang:
|
|
280
|
+
break
|
|
281
|
+
|
|
282
|
+
if not found_hreflang:
|
|
283
|
+
add_finding(
|
|
284
|
+
findings, "WARN", "hreflang", "project", 0,
|
|
285
|
+
"i18n detected but no hreflang tags found in any template",
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
# ---------------------------------------------------------------------------
|
|
290
|
+
# Category 6: robots.txt
|
|
291
|
+
# ---------------------------------------------------------------------------
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def check_robots(project_root: Path, findings: Findings) -> None:
|
|
295
|
+
"""Check for robots.txt at project root or public directory."""
|
|
296
|
+
candidates = [
|
|
297
|
+
project_root / "robots.txt",
|
|
298
|
+
project_root / "public" / "robots.txt",
|
|
299
|
+
project_root / "static" / "robots.txt",
|
|
300
|
+
]
|
|
301
|
+
for candidate in candidates:
|
|
302
|
+
if candidate.exists():
|
|
303
|
+
return
|
|
304
|
+
|
|
305
|
+
add_finding(
|
|
306
|
+
findings, "HIGH", "robots", "project", 0,
|
|
307
|
+
"No robots.txt found at project root or public/",
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
# ---------------------------------------------------------------------------
|
|
312
|
+
# Category 7: Sitemap
|
|
313
|
+
# ---------------------------------------------------------------------------
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def check_sitemap(project_root: Path, findings: Findings) -> None:
|
|
317
|
+
"""Check for sitemap.xml or sitemap configuration."""
|
|
318
|
+
# Direct file check
|
|
319
|
+
sitemap_paths = [
|
|
320
|
+
project_root / "sitemap.xml",
|
|
321
|
+
project_root / "public" / "sitemap.xml",
|
|
322
|
+
project_root / "static" / "sitemap.xml",
|
|
323
|
+
]
|
|
324
|
+
for candidate in sitemap_paths:
|
|
325
|
+
if candidate.exists():
|
|
326
|
+
return
|
|
327
|
+
|
|
328
|
+
# Check for sitemap generation in package.json deps
|
|
329
|
+
pkg_path = project_root / "package.json"
|
|
330
|
+
if pkg_path.exists():
|
|
331
|
+
try:
|
|
332
|
+
pkg_content = pkg_path.read_text(errors="replace")
|
|
333
|
+
sitemap_deps = [
|
|
334
|
+
"next-sitemap", "gatsby-plugin-sitemap", "@nuxtjs/sitemap",
|
|
335
|
+
"sitemap", "vite-plugin-sitemap", "astro-sitemap",
|
|
336
|
+
]
|
|
337
|
+
for dep in sitemap_deps:
|
|
338
|
+
if dep in pkg_content:
|
|
339
|
+
return
|
|
340
|
+
except (OSError, PermissionError):
|
|
341
|
+
pass
|
|
342
|
+
|
|
343
|
+
# Check for sitemap reference in robots.txt
|
|
344
|
+
for robots_path in [
|
|
345
|
+
project_root / "robots.txt",
|
|
346
|
+
project_root / "public" / "robots.txt",
|
|
347
|
+
]:
|
|
348
|
+
if robots_path.exists():
|
|
349
|
+
try:
|
|
350
|
+
robots_content = robots_path.read_text(errors="replace").lower()
|
|
351
|
+
if "sitemap:" in robots_content:
|
|
352
|
+
return
|
|
353
|
+
except (OSError, PermissionError):
|
|
354
|
+
pass
|
|
355
|
+
|
|
356
|
+
add_finding(
|
|
357
|
+
findings, "HIGH", "sitemap", "project", 0,
|
|
358
|
+
"No sitemap.xml or sitemap generator detected",
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
# ---------------------------------------------------------------------------
|
|
363
|
+
# Category 8: Core Web Vitals static signals
|
|
364
|
+
# ---------------------------------------------------------------------------
|
|
365
|
+
|
|
366
|
+
_RE_LAZY_IMG = re.compile(
|
|
367
|
+
r"""<(?:img|Image)\b[^>]*loading\s*=\s*["']lazy["']""", re.IGNORECASE,
|
|
368
|
+
)
|
|
369
|
+
_RE_HERO_COMPONENT = re.compile(
|
|
370
|
+
r"(?:Hero|Banner|Masthead|Jumbotron|HeroSection|CoverImage)", re.IGNORECASE,
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def check_cwv_signals(content: str, rel_path: str, findings: Findings) -> None:
|
|
375
|
+
"""Detect lazy loading on above-fold images and other CWV issues."""
|
|
376
|
+
lines = content.splitlines()
|
|
377
|
+
in_hero_section = False
|
|
378
|
+
|
|
379
|
+
for line_num, line in enumerate(lines, 1):
|
|
380
|
+
# Track hero/banner context
|
|
381
|
+
if _RE_HERO_COMPONENT.search(line):
|
|
382
|
+
in_hero_section = True
|
|
383
|
+
|
|
384
|
+
# Reset hero context after closing tags or significant gaps
|
|
385
|
+
if in_hero_section and re.search(r"</(?:section|div|header)>", line, re.IGNORECASE):
|
|
386
|
+
in_hero_section = False
|
|
387
|
+
|
|
388
|
+
# Lazy loading on first/hero images is harmful for LCP
|
|
389
|
+
if _RE_LAZY_IMG.search(line) and (in_hero_section or line_num <= 30):
|
|
390
|
+
add_finding(
|
|
391
|
+
findings, "HIGH", "cwv", rel_path, line_num,
|
|
392
|
+
"Lazy loading on above-the-fold image delays LCP",
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
# Script in head without async/defer
|
|
396
|
+
if re.search(r"<script\b", line, re.IGNORECASE):
|
|
397
|
+
has_async_defer = re.search(r"\b(async|defer|type\s*=\s*[\"']module[\"'])\b", line, re.IGNORECASE)
|
|
398
|
+
has_json_ld = re.search(r"""type\s*=\s*["']application/ld\+json["']""", line, re.IGNORECASE)
|
|
399
|
+
if not has_async_defer and not has_json_ld:
|
|
400
|
+
# Only flag if in head section
|
|
401
|
+
head_start = content.lower().find("<head")
|
|
402
|
+
head_end = content.lower().find("</head>")
|
|
403
|
+
line_offset = sum(len(l) + 1 for l in lines[:line_num - 1])
|
|
404
|
+
if head_start != -1 and head_end != -1 and head_start < line_offset < head_end:
|
|
405
|
+
add_finding(
|
|
406
|
+
findings, "WARN", "cwv", rel_path, line_num,
|
|
407
|
+
"Script in <head> without async/defer blocks rendering",
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
# ---------------------------------------------------------------------------
|
|
412
|
+
# Category 9: llms.txt (GEO signal)
|
|
413
|
+
# ---------------------------------------------------------------------------
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def check_llms_txt(project_root: Path, findings: Findings) -> None:
|
|
417
|
+
"""Check for llms.txt file (Generative Engine Optimization signal)."""
|
|
418
|
+
candidates = [
|
|
419
|
+
project_root / "llms.txt",
|
|
420
|
+
project_root / "public" / "llms.txt",
|
|
421
|
+
project_root / "static" / "llms.txt",
|
|
422
|
+
]
|
|
423
|
+
for candidate in candidates:
|
|
424
|
+
if candidate.exists():
|
|
425
|
+
return
|
|
426
|
+
|
|
427
|
+
add_finding(
|
|
428
|
+
findings, "INFO", "geo", "project", 0,
|
|
429
|
+
"No llms.txt found (recommended for Generative Engine Optimization)",
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
# ---------------------------------------------------------------------------
|
|
434
|
+
# Main scan orchestrator
|
|
435
|
+
# ---------------------------------------------------------------------------
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def scan(scan_path: Path, project_root: Path) -> dict:
|
|
439
|
+
"""Run all SEO checks and return structured results."""
|
|
440
|
+
files = collect_files(scan_path)
|
|
441
|
+
findings: Findings = []
|
|
442
|
+
|
|
443
|
+
# Per-file checks
|
|
444
|
+
for fpath in files:
|
|
445
|
+
try:
|
|
446
|
+
content = fpath.read_text(errors="replace")
|
|
447
|
+
except (OSError, PermissionError):
|
|
448
|
+
continue
|
|
449
|
+
|
|
450
|
+
rel_path = str(fpath.relative_to(project_root))
|
|
451
|
+
|
|
452
|
+
check_meta_tags(content, rel_path, findings)
|
|
453
|
+
check_headings(content, rel_path, findings)
|
|
454
|
+
check_image_alt(content, rel_path, findings)
|
|
455
|
+
check_structured_data(content, rel_path, findings)
|
|
456
|
+
check_cwv_signals(content, rel_path, findings)
|
|
457
|
+
|
|
458
|
+
# Project-level checks
|
|
459
|
+
check_hreflang(project_root, findings)
|
|
460
|
+
check_robots(project_root, findings)
|
|
461
|
+
check_sitemap(project_root, findings)
|
|
462
|
+
check_llms_txt(project_root, findings)
|
|
463
|
+
|
|
464
|
+
# Deduplicate
|
|
465
|
+
seen: set[tuple[str, int, str]] = set()
|
|
466
|
+
deduped: Findings = []
|
|
467
|
+
for f in findings:
|
|
468
|
+
key = (f["file"], f["line"], f["message"])
|
|
469
|
+
if key not in seen:
|
|
470
|
+
seen.add(key)
|
|
471
|
+
deduped.append(f)
|
|
472
|
+
findings = deduped
|
|
473
|
+
|
|
474
|
+
# Sort by severity (HIGH -> WARN -> INFO), then file, then line
|
|
475
|
+
sev_order = {"HIGH": 0, "WARN": 1, "INFO": 2}
|
|
476
|
+
findings.sort(key=lambda f: (sev_order.get(f["severity"], 9), f["file"], f["line"]))
|
|
477
|
+
|
|
478
|
+
# Build summary
|
|
479
|
+
summary = {"HIGH": 0, "WARN": 0, "INFO": 0}
|
|
480
|
+
for f in findings:
|
|
481
|
+
sev = f["severity"]
|
|
482
|
+
summary[sev] = summary.get(sev, 0) + 1
|
|
483
|
+
|
|
484
|
+
rel_scan = str(scan_path.relative_to(project_root)) if scan_path != project_root else "."
|
|
485
|
+
|
|
486
|
+
return {
|
|
487
|
+
"scan_path": rel_scan,
|
|
488
|
+
"files_scanned": len(files),
|
|
489
|
+
"findings": findings,
|
|
490
|
+
"summary": summary,
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
# ---------------------------------------------------------------------------
|
|
495
|
+
# Entry point
|
|
496
|
+
# ---------------------------------------------------------------------------
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def main() -> None:
|
|
500
|
+
parser = argparse.ArgumentParser(
|
|
501
|
+
description="SEO scanner -- detect common SEO issues in HTML/JSX/TSX/Vue/Astro/Svelte files",
|
|
502
|
+
)
|
|
503
|
+
parser.add_argument(
|
|
504
|
+
"path", nargs="?", default=".",
|
|
505
|
+
help="Directory or file to scan (default: current directory)",
|
|
506
|
+
)
|
|
507
|
+
parser.add_argument(
|
|
508
|
+
"--output", choices=["json", "text"], default="json",
|
|
509
|
+
help="Output format (default: json)",
|
|
510
|
+
)
|
|
511
|
+
args = parser.parse_args()
|
|
512
|
+
|
|
513
|
+
target = Path(args.path).resolve()
|
|
514
|
+
if not target.exists():
|
|
515
|
+
print(json.dumps({"error": f"Path does not exist: {target}"}), file=sys.stderr)
|
|
516
|
+
sys.exit(2)
|
|
517
|
+
|
|
518
|
+
scan_path = target if target.is_dir() else target.parent
|
|
519
|
+
project_root = find_project_root(scan_path)
|
|
520
|
+
|
|
521
|
+
result = scan(scan_path, project_root)
|
|
522
|
+
|
|
523
|
+
if args.output == "json":
|
|
524
|
+
print(json.dumps(result, indent=2))
|
|
525
|
+
else:
|
|
526
|
+
_print_text_report(result)
|
|
527
|
+
|
|
528
|
+
# Exit code: non-zero if HIGH findings exist
|
|
529
|
+
if result["summary"].get("HIGH", 0) > 0:
|
|
530
|
+
sys.exit(1)
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def _print_text_report(result: dict) -> None:
|
|
534
|
+
"""Print a human-readable text report."""
|
|
535
|
+
summary = result["summary"]
|
|
536
|
+
print(f"\nSEO Scan: {result['scan_path']}")
|
|
537
|
+
print(f"Files scanned: {result['files_scanned']}")
|
|
538
|
+
print(f"HIGH: {summary.get('HIGH', 0)} WARN: {summary.get('WARN', 0)} INFO: {summary.get('INFO', 0)}")
|
|
539
|
+
print()
|
|
540
|
+
|
|
541
|
+
for f in result["findings"]:
|
|
542
|
+
line_str = f":{f['line']}" if f["line"] > 0 else ""
|
|
543
|
+
print(f"[{f['severity']}] {f['category']} | {f['file']}{line_str}")
|
|
544
|
+
print(f" {f['message']}")
|
|
545
|
+
print()
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
if __name__ == "__main__":
|
|
549
|
+
main()
|
package/bin/ai-toolkit.js
CHANGED
|
@@ -58,8 +58,8 @@ const SCRIPT_COMMANDS = {
|
|
|
58
58
|
|
|
59
59
|
/** @type {Record<string, string>} */
|
|
60
60
|
const COMMANDS = {
|
|
61
|
-
install: 'First-time global install into ~/.claude/ (use --local
|
|
62
|
-
update: 'Re-apply toolkit with saved modules from state.json (use --local
|
|
61
|
+
install: 'First-time global install into ~/.claude/ (use --local for project-local configs only)',
|
|
62
|
+
update: 'Re-apply toolkit with saved modules from state.json (use --local for project-local only)',
|
|
63
63
|
status: 'Show installed modules, version, and profile from state.json',
|
|
64
64
|
reset: 'Wipe and recreate project-local configs from scratch (requires --local)',
|
|
65
65
|
uninstall: 'Remove ai-toolkit from ~/.claude/',
|
|
@@ -218,9 +218,9 @@ function showHelp() {
|
|
|
218
218
|
console.log(` ${cmd.padEnd(16)} ${desc}`);
|
|
219
219
|
}
|
|
220
220
|
console.log('\nOptions for install / update:');
|
|
221
|
-
console.log(' --only <list> Apply only listed components (e.g. agents,hooks,
|
|
221
|
+
console.log(' --only <list> Apply only listed components (e.g. agents,hooks,rules,skills,constitution)');
|
|
222
222
|
console.log(' --skip <list> Skip listed components');
|
|
223
|
-
console.log(' --local
|
|
223
|
+
console.log(' --local Project-local configs only (CLAUDE.md, settings, constitution, language rules, git hooks)');
|
|
224
224
|
console.log(' --profile <p> Install profile: minimal (agents+skills), standard (default), strict (all+git hooks)');
|
|
225
225
|
console.log(' --persona <p> Persona preset: backend-lead, frontend-lead, devops-eng, junior-dev');
|
|
226
226
|
console.log(' --modules <list> Install specific modules (e.g. core,agents,rules-typescript)');
|
|
@@ -185,7 +185,7 @@ Three tiers determine how to approach a task:
|
|
|
185
185
|
|
|
186
186
|
| Type | Field | Invocation | Count |
|
|
187
187
|
|------|-------|-----------|-------|
|
|
188
|
-
| Task | `disable-model-invocation: true` | User via `/skill` only |
|
|
188
|
+
| Task | `disable-model-invocation: true` | User via `/skill` only | 31 |
|
|
189
189
|
| Hybrid | (neither) | User via `/skill` + agent knowledge | 31 |
|
|
190
190
|
| Knowledge | `user-invocable: false` | Claude auto-loads | 32 |
|
|
191
191
|
|
|
@@ -12,7 +12,7 @@ description: "Feature comparison of ai-toolkit vs other Claude Code toolkits and
|
|
|
12
12
|
|
|
13
13
|
| Feature | ai-toolkit | everything-claude-code | wshobson/agents | ruflo |
|
|
14
14
|
|---------|---------------|----------------------|-----------------|-------|
|
|
15
|
-
| Skills |
|
|
15
|
+
| Skills | 93 | 100+ | 146 | 20+ |
|
|
16
16
|
| Agents | 44 | 30+ | 112 | 20+ |
|
|
17
17
|
| Machine-enforced constitution | **Yes** | No (docs only) | No | No |
|
|
18
18
|
| Skill-scoped lifecycle hooks | **Yes** | No | No | No |
|
|
@@ -23,7 +23,7 @@ All functionality is unified under skills. Task and hybrid skills are user-invoc
|
|
|
23
23
|
| **2 — Multi-agent workflow** | `/workflow <type>` | Cross-cutting task with known pattern |
|
|
24
24
|
| **3 — Custom parallelism** | `/orchestrate`, `/swarm` | No predefined workflow matches |
|
|
25
25
|
|
|
26
|
-
## Task Skills (
|
|
26
|
+
## Task Skills (30)
|
|
27
27
|
|
|
28
28
|
Task skills execute a specific action. Invoked via slash commands. `disable-model-invocation: true`.
|
|
29
29
|
|
|
@@ -58,6 +58,8 @@ Task skills execute a specific action. Invoked via slash commands. `disable-mode
|
|
|
58
58
|
| **prd-to-issues** | `/prd-to-issues` | medium | Break PRD into GitHub issues with vertical slices and HITL/AFK tagging |
|
|
59
59
|
| **skill-audit** | `/skill-audit` | medium | Scan skills and agents for security risks: dangerous patterns, secrets, excessive permissions |
|
|
60
60
|
| **hipaa-validate** | `/hipaa-validate` | medium | Scan codebase for HIPAA compliance issues: PHI exposure, missing audit logging, unencrypted transmission/storage, access control gaps, temp file exposure, and missing BAA references |
|
|
61
|
+
| **a11y-validate** | `/a11y-validate` | medium | Scan codebase for accessibility violations: WCAG 2.1 Level AA, EN 301 549, European Accessibility Act (EAA / EU 2019/882). Covers semantics, keyboard, focus, color contrast, forms, media, ARIA, motion, mobile (React Native + Flutter), and EAA accessibility-statement documentation. |
|
|
62
|
+
| **seo-validate** | `/seo-validate` | medium | Scan codebase for SEO issues: W3C semantics, meta/OG tags, Schema.org, hreflang, Core Web Vitals (LCP/INP/CLS), resource hints, GEO, SPA/SSG/CSR crawlability, technical SEO, accessibility-for-SEO. Framework-aware (Next/Nuxt/Astro/Gatsby/SvelteKit/Remix/Angular/Vue/static HTML). |
|
|
61
63
|
|
|
62
64
|
## Hybrid Skills (31)
|
|
63
65
|
|