@softspark/ai-toolkit 2.4.0 → 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 +45 -0
- package/README.md +13 -12
- package/app/.claude-plugin/plugin.json +1 -1
- package/app/ARCHITECTURE.md +2 -2
- 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 +32 -5
- package/kb/reference/architecture-overview.md +3 -3
- package/kb/reference/cli-reference.md +1 -1
- package/kb/reference/codex-cli-compatibility.md +4 -0
- package/kb/reference/comparison.md +1 -1
- package/kb/reference/extension-api.md +2 -0
- package/kb/reference/skills-catalog.md +3 -1
- package/llms-full.txt +16 -6
- package/manifest.json +3 -3
- package/package.json +2 -2
- package/scripts/config_cli.py +4 -10
- package/scripts/config_resolver.py +23 -5
- package/scripts/doctor.py +76 -4
- package/scripts/hook_sources.py +3 -0
- package/scripts/inject_hook_cli.py +74 -1
- package/scripts/install.py +34 -3
- package/scripts/install_steps/ai_tools.py +79 -16
- package/scripts/install_steps/install_state.py +25 -0
- package/scripts/install_steps/markers.py +2 -1
- package/scripts/install_steps/project_registry.py +9 -0
- package/scripts/plugin.py +1 -1
- package/scripts/propagate_global.py +92 -0
- package/scripts/rule_sources.py +3 -2
- package/scripts/update_projects.py +7 -1
- package/scripts/url_fetch.py +5 -0
|
@@ -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
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
|
-
const { execFileSync, spawnSync
|
|
4
|
+
const { execFileSync, spawnSync } = require('child_process');
|
|
5
5
|
const path = require('path');
|
|
6
6
|
const fs = require('fs');
|
|
7
7
|
|
|
@@ -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/',
|
|
@@ -151,6 +151,23 @@ function run(script, args = [], opts = {}) {
|
|
|
151
151
|
}
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
+
/**
|
|
155
|
+
* Propagate changes to globally installed editors (from state.json).
|
|
156
|
+
* Silently skips if no global editors are configured.
|
|
157
|
+
* @param {...string} flags - Flags to pass: --rules, --hooks, --mcp
|
|
158
|
+
*/
|
|
159
|
+
function propagateGlobal(...flags) {
|
|
160
|
+
const result = spawnSync('python3', [scriptPath('propagate_global.py'), ...flags], {
|
|
161
|
+
stdio: 'inherit',
|
|
162
|
+
cwd: CWD,
|
|
163
|
+
env: { ...process.env },
|
|
164
|
+
});
|
|
165
|
+
// Non-fatal — propagation failure shouldn't block the primary operation
|
|
166
|
+
if (result.status !== 0) {
|
|
167
|
+
console.error('Warning: global editor propagation had issues (non-fatal)');
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
154
171
|
/**
|
|
155
172
|
* Generic dispatcher for SCRIPT_COMMANDS entries.
|
|
156
173
|
* Resolves the script path and selects the correct cwd.
|
|
@@ -201,9 +218,9 @@ function showHelp() {
|
|
|
201
218
|
console.log(` ${cmd.padEnd(16)} ${desc}`);
|
|
202
219
|
}
|
|
203
220
|
console.log('\nOptions for install / update:');
|
|
204
|
-
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)');
|
|
205
222
|
console.log(' --skip <list> Skip listed components');
|
|
206
|
-
console.log(' --local
|
|
223
|
+
console.log(' --local Project-local configs only (CLAUDE.md, settings, constitution, language rules, git hooks)');
|
|
207
224
|
console.log(' --profile <p> Install profile: minimal (agents+skills), standard (default), strict (all+git hooks)');
|
|
208
225
|
console.log(' --persona <p> Persona preset: backend-lead, frontend-lead, devops-eng, junior-dev');
|
|
209
226
|
console.log(' --modules <list> Install specific modules (e.g. core,agents,rules-typescript)');
|
|
@@ -331,6 +348,7 @@ function handleRemoveRule(args) {
|
|
|
331
348
|
}
|
|
332
349
|
const targetDir = args[1] || process.env.HOME;
|
|
333
350
|
run(scriptPath('remove_rule.py'), [ruleName, targetDir]);
|
|
351
|
+
propagateGlobal('--rules');
|
|
334
352
|
}
|
|
335
353
|
|
|
336
354
|
/**
|
|
@@ -345,9 +363,14 @@ function handleAddRule(args) {
|
|
|
345
363
|
}
|
|
346
364
|
// Pass URLs through directly (don't resolve as filesystem path)
|
|
347
365
|
const isUrl = ruleFile.startsWith('https://') || ruleFile.startsWith('http://');
|
|
366
|
+
if (ruleFile.startsWith('http://')) {
|
|
367
|
+
console.error('Error: only HTTPS URLs are supported. Use https:// for security.');
|
|
368
|
+
process.exit(1);
|
|
369
|
+
}
|
|
348
370
|
const absRuleFile = isUrl ? ruleFile : path.resolve(CWD, ruleFile);
|
|
349
371
|
const ruleName = args[1];
|
|
350
372
|
run(scriptPath('add_rule.py'), ruleName ? [absRuleFile, ruleName] : [absRuleFile]);
|
|
373
|
+
propagateGlobal('--rules');
|
|
351
374
|
}
|
|
352
375
|
|
|
353
376
|
/**
|
|
@@ -392,6 +415,10 @@ function handleMcp(args) {
|
|
|
392
415
|
process.exit(1);
|
|
393
416
|
}
|
|
394
417
|
run(scriptPath('mcp_manager.py'), args);
|
|
418
|
+
// After `mcp add`, propagate to global editors
|
|
419
|
+
if (args[0] === 'add') {
|
|
420
|
+
propagateGlobal('--mcp');
|
|
421
|
+
}
|
|
395
422
|
}
|
|
396
423
|
|
|
397
424
|
/**
|
|
@@ -5,7 +5,7 @@ service: ai-toolkit
|
|
|
5
5
|
tags: [architecture, overview, design, structure]
|
|
6
6
|
version: "1.4.4"
|
|
7
7
|
created: "2026-03-23"
|
|
8
|
-
last_updated: "2026-04-
|
|
8
|
+
last_updated: "2026-04-15"
|
|
9
9
|
description: "Architecture of ai-toolkit: directory layout, global install model, editor-aware MCP install, Codex translation layer, skill tiers, and integration with projects."
|
|
10
10
|
---
|
|
11
11
|
|
|
@@ -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
|
|
|
@@ -335,7 +335,7 @@ Severity levels: HIGH (blocks deployment), WARN (should fix), INFO (best practic
|
|
|
335
335
|
## Extension Points
|
|
336
336
|
|
|
337
337
|
### MCP Templates
|
|
338
|
-
`app/
|
|
338
|
+
`app/mcp-templates/` contains 26 ready-to-use MCP server config templates. Opt-in via `ai-toolkit install --modules mcp-templates` or activated automatically with `--profile strict|full`.
|
|
339
339
|
|
|
340
340
|
### Language Rules
|
|
341
341
|
`app/rules/` provides language-specific rule files covering 13 languages (TypeScript, Python, Go, Rust, Java, Kotlin, Swift, Dart, C#, PHP, C++, Ruby, common). Auto-detected from project files via `--auto-detect` or selectable with `--modules rules-<lang>`. See README.md for current count.
|
|
@@ -4,7 +4,7 @@ category: reference
|
|
|
4
4
|
service: ai-toolkit
|
|
5
5
|
tags: [cli, commands, reference, install, update, plugin, mcp]
|
|
6
6
|
created: "2026-04-13"
|
|
7
|
-
last_updated: "2026-04-
|
|
7
|
+
last_updated: "2026-04-15"
|
|
8
8
|
description: "Complete CLI reference for all ai-toolkit commands, options, and flags."
|
|
9
9
|
---
|
|
10
10
|
|
|
@@ -107,6 +107,10 @@ This means Claude-only events such as `TaskCompleted`, `TeammateIdle`,
|
|
|
107
107
|
`SubagentStart`, `SubagentStop`, `PreCompact`, `SessionEnd`, and
|
|
108
108
|
`Notification` are not available in `.codex/hooks.json`.
|
|
109
109
|
|
|
110
|
+
`inject-hook` automatically propagates Codex-compatible events to
|
|
111
|
+
`~/.codex/hooks.json` (global layer). Non-Codex events are silently skipped.
|
|
112
|
+
`remove-hook` cleans both Claude and Codex targets.
|
|
113
|
+
|
|
110
114
|
## Behavioral Limits
|
|
111
115
|
|
|
112
116
|
Codex wrappers preserve workflow intent, but not every Claude runtime behavior
|