@elyracode/seo 0.7.8
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/CHANGELOG.md +10 -0
- package/README.md +56 -0
- package/extensions/index.ts +746 -0
- package/package.json +37 -0
- package/skills/elyra-seo/SKILL.md +126 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.7.8] - 2026-05-23
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- `seo_audit` tool: analyze project files for SEO and LLM readability issues
|
|
7
|
+
- `seo_generate_llms_txt` tool: generate `llms.txt` from project content
|
|
8
|
+
- `seo_generate_schema` tool: generate JSON-LD structured data for pages
|
|
9
|
+
- `seo_generate_meta` tool: audit and generate missing meta tags
|
|
10
|
+
- `elyra-seo` skill: SEO and LLM optimization best practices
|
package/README.md
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# @elyracode/seo
|
|
2
|
+
|
|
3
|
+
SEO and LLM optimization extension for Elyra. Audits your project for SEO issues, generates structured data, creates `llms.txt` files, and ensures your content is discoverable by both search engines and AI assistants.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
elyra install @elyracode/seo
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Available Tools
|
|
12
|
+
|
|
13
|
+
| Tool | Description |
|
|
14
|
+
|------|-------------|
|
|
15
|
+
| `seo_audit` | Analyze project files for SEO and LLM readability issues |
|
|
16
|
+
| `seo_generate_llms_txt` | Generate `llms.txt` and `llms-full.txt` from project content |
|
|
17
|
+
| `seo_generate_schema` | Generate or fix JSON-LD structured data for pages |
|
|
18
|
+
| `seo_generate_meta` | Audit and generate missing meta tags, OpenGraph, and Twitter Cards |
|
|
19
|
+
|
|
20
|
+
## Usage
|
|
21
|
+
|
|
22
|
+
The extension activates automatically when your project contains HTML, JSX, TSX, Vue, or Blade template files.
|
|
23
|
+
|
|
24
|
+
### Audit a project
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
Audit this site for SEO and LLM readability
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
The agent runs `seo_audit` and reports issues across all template files: missing meta tags, absent structured data, images without alt text, missing `llms.txt`, heading hierarchy problems, and LLM readability concerns.
|
|
31
|
+
|
|
32
|
+
### Generate llms.txt
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
Generate an llms.txt for this project
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Creates a `llms.txt` following the specification at llmstxt.org, with sections for documentation, API references, and key pages extracted from your project structure.
|
|
39
|
+
|
|
40
|
+
### Add structured data
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
Add JSON-LD structured data to the product pages
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
The agent analyzes page content and generates appropriate schema.org markup (Product, Article, FAQPage, Organization, etc.).
|
|
47
|
+
|
|
48
|
+
## Included Skill
|
|
49
|
+
|
|
50
|
+
The `elyra-seo` skill provides the agent with knowledge about:
|
|
51
|
+
|
|
52
|
+
- SEO best practices and common pitfalls
|
|
53
|
+
- `llms.txt` specification and format
|
|
54
|
+
- Schema.org types for web content
|
|
55
|
+
- LLM optimization patterns (structured content, entity definition, citable text)
|
|
56
|
+
- OpenGraph and Twitter Card meta tag requirements
|
|
@@ -0,0 +1,746 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { basename, extname, join, relative } from "node:path";
|
|
3
|
+
import type { ExtensionAPI } from "@elyracode/coding-agent";
|
|
4
|
+
import { Type } from "typebox";
|
|
5
|
+
|
|
6
|
+
// ── Types ───────────────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
interface AuditIssue {
|
|
9
|
+
file: string;
|
|
10
|
+
line?: number;
|
|
11
|
+
severity: "critical" | "high" | "medium" | "low";
|
|
12
|
+
category: string;
|
|
13
|
+
message: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface PageMeta {
|
|
17
|
+
file: string;
|
|
18
|
+
hasTitle: boolean;
|
|
19
|
+
hasDescription: boolean;
|
|
20
|
+
hasCanonical: boolean;
|
|
21
|
+
hasOgTitle: boolean;
|
|
22
|
+
hasOgDescription: boolean;
|
|
23
|
+
hasOgImage: boolean;
|
|
24
|
+
hasTwitterCard: boolean;
|
|
25
|
+
hasJsonLd: boolean;
|
|
26
|
+
hasH1: boolean;
|
|
27
|
+
h1Count: number;
|
|
28
|
+
imagesWithoutAlt: number;
|
|
29
|
+
headingLevels: number[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
const TEMPLATE_EXTENSIONS = new Set([
|
|
35
|
+
".html",
|
|
36
|
+
".htm",
|
|
37
|
+
".jsx",
|
|
38
|
+
".tsx",
|
|
39
|
+
".vue",
|
|
40
|
+
".svelte",
|
|
41
|
+
".astro",
|
|
42
|
+
".blade.php",
|
|
43
|
+
".ejs",
|
|
44
|
+
".hbs",
|
|
45
|
+
".njk",
|
|
46
|
+
".erb",
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
function isTemplateFile(filePath: string): boolean {
|
|
50
|
+
const name = basename(filePath);
|
|
51
|
+
if (name.endsWith(".blade.php")) return true;
|
|
52
|
+
return TEMPLATE_EXTENSIONS.has(extname(filePath));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function walkDir(dir: string, maxDepth = 8): string[] {
|
|
56
|
+
const results: string[] = [];
|
|
57
|
+
const ignored = new Set(["node_modules", ".git", ".next", ".nuxt", "dist", "build", ".output", "vendor", "__pycache__"]);
|
|
58
|
+
|
|
59
|
+
function walk(current: string, depth: number): void {
|
|
60
|
+
if (depth > maxDepth) return;
|
|
61
|
+
let entries: string[];
|
|
62
|
+
try {
|
|
63
|
+
entries = readdirSync(current);
|
|
64
|
+
} catch {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
for (const entry of entries) {
|
|
68
|
+
if (ignored.has(entry) || entry.startsWith(".")) continue;
|
|
69
|
+
const full = join(current, entry);
|
|
70
|
+
try {
|
|
71
|
+
const stat = statSync(full);
|
|
72
|
+
if (stat.isDirectory()) {
|
|
73
|
+
walk(full, depth + 1);
|
|
74
|
+
} else if (stat.isFile() && isTemplateFile(full)) {
|
|
75
|
+
results.push(full);
|
|
76
|
+
}
|
|
77
|
+
} catch {
|
|
78
|
+
// Skip inaccessible files
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
walk(dir, 0);
|
|
83
|
+
return results;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function analyzePage(filePath: string, content: string): PageMeta {
|
|
87
|
+
const lower = content.toLowerCase();
|
|
88
|
+
|
|
89
|
+
// Title
|
|
90
|
+
const hasTitle = /<title[\s>]/i.test(content) || /["']title["']\s*:/i.test(content);
|
|
91
|
+
|
|
92
|
+
// Meta description
|
|
93
|
+
const hasDescription =
|
|
94
|
+
/meta\s[^>]*name\s*=\s*["']description["']/i.test(content) ||
|
|
95
|
+
/description\s*:/i.test(content);
|
|
96
|
+
|
|
97
|
+
// Canonical
|
|
98
|
+
const hasCanonical = /rel\s*=\s*["']canonical["']/i.test(content);
|
|
99
|
+
|
|
100
|
+
// OpenGraph
|
|
101
|
+
const hasOgTitle = /property\s*=\s*["']og:title["']/i.test(content);
|
|
102
|
+
const hasOgDescription = /property\s*=\s*["']og:description["']/i.test(content);
|
|
103
|
+
const hasOgImage = /property\s*=\s*["']og:image["']/i.test(content);
|
|
104
|
+
|
|
105
|
+
// Twitter
|
|
106
|
+
const hasTwitterCard = /name\s*=\s*["']twitter:card["']/i.test(content);
|
|
107
|
+
|
|
108
|
+
// JSON-LD
|
|
109
|
+
const hasJsonLd = /application\/ld\+json/i.test(content);
|
|
110
|
+
|
|
111
|
+
// Headings
|
|
112
|
+
const h1Matches = content.match(/<h1[\s>]/gi) ?? [];
|
|
113
|
+
const hasH1 = h1Matches.length > 0;
|
|
114
|
+
const h1Count = h1Matches.length;
|
|
115
|
+
|
|
116
|
+
// Heading levels used
|
|
117
|
+
const headingLevels: number[] = [];
|
|
118
|
+
for (let i = 1; i <= 6; i++) {
|
|
119
|
+
if (new RegExp(`<h${i}[\\s>]`, "i").test(content)) {
|
|
120
|
+
headingLevels.push(i);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Images without alt
|
|
125
|
+
const imgTags = content.match(/<img\s[^>]*>/gi) ?? [];
|
|
126
|
+
let imagesWithoutAlt = 0;
|
|
127
|
+
for (const img of imgTags) {
|
|
128
|
+
if (!/alt\s*=\s*["'][^"']+["']/i.test(img)) {
|
|
129
|
+
imagesWithoutAlt++;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
file: filePath,
|
|
135
|
+
hasTitle,
|
|
136
|
+
hasDescription,
|
|
137
|
+
hasCanonical,
|
|
138
|
+
hasOgTitle,
|
|
139
|
+
hasOgDescription,
|
|
140
|
+
hasOgImage,
|
|
141
|
+
hasTwitterCard,
|
|
142
|
+
hasJsonLd,
|
|
143
|
+
hasH1,
|
|
144
|
+
h1Count,
|
|
145
|
+
imagesWithoutAlt,
|
|
146
|
+
headingLevels,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function auditPage(filePath: string, relPath: string, content: string): AuditIssue[] {
|
|
151
|
+
const issues: AuditIssue[] = [];
|
|
152
|
+
const meta = analyzePage(filePath, content);
|
|
153
|
+
|
|
154
|
+
if (!meta.hasTitle) {
|
|
155
|
+
issues.push({ file: relPath, severity: "critical", category: "meta", message: "Missing <title> tag" });
|
|
156
|
+
}
|
|
157
|
+
if (!meta.hasDescription) {
|
|
158
|
+
issues.push({ file: relPath, severity: "high", category: "meta", message: "Missing meta description" });
|
|
159
|
+
}
|
|
160
|
+
if (!meta.hasCanonical) {
|
|
161
|
+
issues.push({ file: relPath, severity: "medium", category: "meta", message: "Missing canonical URL" });
|
|
162
|
+
}
|
|
163
|
+
if (!meta.hasH1) {
|
|
164
|
+
issues.push({ file: relPath, severity: "high", category: "headings", message: "Missing H1 tag" });
|
|
165
|
+
}
|
|
166
|
+
if (meta.h1Count > 1) {
|
|
167
|
+
issues.push({
|
|
168
|
+
file: relPath,
|
|
169
|
+
severity: "medium",
|
|
170
|
+
category: "headings",
|
|
171
|
+
message: `Multiple H1 tags (${meta.h1Count})`,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Check heading hierarchy
|
|
176
|
+
const sorted = [...meta.headingLevels].sort();
|
|
177
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
178
|
+
if (sorted[i] - sorted[i - 1] > 1) {
|
|
179
|
+
issues.push({
|
|
180
|
+
file: relPath,
|
|
181
|
+
severity: "low",
|
|
182
|
+
category: "headings",
|
|
183
|
+
message: `Skipped heading level: H${sorted[i - 1]} to H${sorted[i]}`,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (meta.imagesWithoutAlt > 0) {
|
|
189
|
+
issues.push({
|
|
190
|
+
file: relPath,
|
|
191
|
+
severity: "medium",
|
|
192
|
+
category: "accessibility",
|
|
193
|
+
message: `${meta.imagesWithoutAlt} image(s) without alt text`,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
if (!meta.hasJsonLd) {
|
|
197
|
+
issues.push({
|
|
198
|
+
file: relPath,
|
|
199
|
+
severity: "medium",
|
|
200
|
+
category: "structured-data",
|
|
201
|
+
message: "No JSON-LD structured data found",
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
if (!meta.hasOgTitle || !meta.hasOgDescription || !meta.hasOgImage) {
|
|
205
|
+
const missing: string[] = [];
|
|
206
|
+
if (!meta.hasOgTitle) missing.push("og:title");
|
|
207
|
+
if (!meta.hasOgDescription) missing.push("og:description");
|
|
208
|
+
if (!meta.hasOgImage) missing.push("og:image");
|
|
209
|
+
issues.push({
|
|
210
|
+
file: relPath,
|
|
211
|
+
severity: "low",
|
|
212
|
+
category: "social",
|
|
213
|
+
message: `Missing OpenGraph tags: ${missing.join(", ")}`,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
if (!meta.hasTwitterCard) {
|
|
217
|
+
issues.push({
|
|
218
|
+
file: relPath,
|
|
219
|
+
severity: "low",
|
|
220
|
+
category: "social",
|
|
221
|
+
message: "Missing Twitter Card meta tags",
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return issues;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function formatIssues(issues: AuditIssue[]): string {
|
|
229
|
+
if (issues.length === 0) return "No issues found.";
|
|
230
|
+
|
|
231
|
+
const bySeverity = { critical: 0, high: 0, medium: 0, low: 0 };
|
|
232
|
+
for (const issue of issues) {
|
|
233
|
+
bySeverity[issue.severity]++;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const lines: string[] = [];
|
|
237
|
+
lines.push("## Summary");
|
|
238
|
+
lines.push("");
|
|
239
|
+
const total = issues.length;
|
|
240
|
+
const parts: string[] = [];
|
|
241
|
+
if (bySeverity.critical > 0) parts.push(`${bySeverity.critical} critical`);
|
|
242
|
+
if (bySeverity.high > 0) parts.push(`${bySeverity.high} high`);
|
|
243
|
+
if (bySeverity.medium > 0) parts.push(`${bySeverity.medium} medium`);
|
|
244
|
+
if (bySeverity.low > 0) parts.push(`${bySeverity.low} low`);
|
|
245
|
+
lines.push(`${total} issues found: ${parts.join(", ")}`);
|
|
246
|
+
lines.push("");
|
|
247
|
+
|
|
248
|
+
// Group by file
|
|
249
|
+
const byFile = new Map<string, AuditIssue[]>();
|
|
250
|
+
for (const issue of issues) {
|
|
251
|
+
const list = byFile.get(issue.file) ?? [];
|
|
252
|
+
list.push(issue);
|
|
253
|
+
byFile.set(issue.file, list);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
for (const [file, fileIssues] of byFile) {
|
|
257
|
+
lines.push(`### ${file}`);
|
|
258
|
+
lines.push("");
|
|
259
|
+
for (const issue of fileIssues) {
|
|
260
|
+
const icon =
|
|
261
|
+
issue.severity === "critical"
|
|
262
|
+
? "[CRITICAL]"
|
|
263
|
+
: issue.severity === "high"
|
|
264
|
+
? "[HIGH]"
|
|
265
|
+
: issue.severity === "medium"
|
|
266
|
+
? "[MEDIUM]"
|
|
267
|
+
: "[LOW]";
|
|
268
|
+
lines.push(`- ${icon} ${issue.message}`);
|
|
269
|
+
}
|
|
270
|
+
lines.push("");
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return lines.join("\n");
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function detectSiteStructure(
|
|
277
|
+
cwd: string,
|
|
278
|
+
): { pages: Array<{ title: string; path: string }>; framework: string | null } {
|
|
279
|
+
const pages: Array<{ title: string; path: string }> = [];
|
|
280
|
+
let framework: string | null = null;
|
|
281
|
+
|
|
282
|
+
// Detect framework
|
|
283
|
+
const pkgPath = join(cwd, "package.json");
|
|
284
|
+
if (existsSync(pkgPath)) {
|
|
285
|
+
try {
|
|
286
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as Record<string, unknown>;
|
|
287
|
+
const deps = {
|
|
288
|
+
...(pkg.dependencies as Record<string, string> | undefined),
|
|
289
|
+
...(pkg.devDependencies as Record<string, string> | undefined),
|
|
290
|
+
};
|
|
291
|
+
if (deps.next) framework = "Next.js";
|
|
292
|
+
else if (deps.nuxt) framework = "Nuxt";
|
|
293
|
+
else if (deps.astro) framework = "Astro";
|
|
294
|
+
else if (deps.svelte || deps["@sveltejs/kit"]) framework = "SvelteKit";
|
|
295
|
+
else if (deps.vue) framework = "Vue";
|
|
296
|
+
else if (deps.react) framework = "React";
|
|
297
|
+
} catch {
|
|
298
|
+
// ignore
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
if (!framework && existsSync(join(cwd, "composer.json"))) {
|
|
302
|
+
framework = "Laravel";
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Scan for page-like routes
|
|
306
|
+
const routeDirs = [
|
|
307
|
+
"src/app",
|
|
308
|
+
"src/pages",
|
|
309
|
+
"pages",
|
|
310
|
+
"app",
|
|
311
|
+
"src/routes",
|
|
312
|
+
"routes",
|
|
313
|
+
"resources/views",
|
|
314
|
+
"resources/views/pages",
|
|
315
|
+
];
|
|
316
|
+
|
|
317
|
+
for (const dir of routeDirs) {
|
|
318
|
+
const fullDir = join(cwd, dir);
|
|
319
|
+
if (!existsSync(fullDir)) continue;
|
|
320
|
+
try {
|
|
321
|
+
const files = walkDir(fullDir, 3);
|
|
322
|
+
for (const file of files) {
|
|
323
|
+
const rel = relative(cwd, file);
|
|
324
|
+
const name = basename(file, extname(file))
|
|
325
|
+
.replace(/\.(page|route|view|blade)$/i, "")
|
|
326
|
+
.replace(/[-_]/g, " ")
|
|
327
|
+
.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
328
|
+
if (name.toLowerCase() === "index" || name.toLowerCase() === "layout") continue;
|
|
329
|
+
pages.push({ title: name, path: `/${relative(fullDir, file).replace(extname(file), "")}` });
|
|
330
|
+
}
|
|
331
|
+
} catch {
|
|
332
|
+
// ignore
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return { pages, framework };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function generateLlmsTxt(
|
|
340
|
+
cwd: string,
|
|
341
|
+
siteUrl: string,
|
|
342
|
+
): string {
|
|
343
|
+
const { pages, framework } = detectSiteStructure(cwd);
|
|
344
|
+
|
|
345
|
+
// Try to extract site name from package.json
|
|
346
|
+
let siteName = "My Site";
|
|
347
|
+
const pkgPath = join(cwd, "package.json");
|
|
348
|
+
if (existsSync(pkgPath)) {
|
|
349
|
+
try {
|
|
350
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as Record<string, unknown>;
|
|
351
|
+
if (typeof pkg.name === "string") {
|
|
352
|
+
siteName = pkg.name
|
|
353
|
+
.replace(/^@[^/]+\//, "")
|
|
354
|
+
.replace(/[-_]/g, " ")
|
|
355
|
+
.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
356
|
+
}
|
|
357
|
+
if (typeof pkg.description === "string") {
|
|
358
|
+
siteName = `${siteName}\n\n> ${pkg.description}`;
|
|
359
|
+
}
|
|
360
|
+
} catch {
|
|
361
|
+
// ignore
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const lines: string[] = [];
|
|
366
|
+
lines.push(`# ${siteName}`);
|
|
367
|
+
lines.push("");
|
|
368
|
+
|
|
369
|
+
if (pages.length > 0) {
|
|
370
|
+
lines.push("## Pages");
|
|
371
|
+
for (const page of pages.slice(0, 30)) {
|
|
372
|
+
const url = siteUrl.replace(/\/$/, "") + page.path;
|
|
373
|
+
lines.push(`- [${page.title}](${url})`);
|
|
374
|
+
}
|
|
375
|
+
lines.push("");
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
lines.push("## Optional");
|
|
379
|
+
lines.push(`- [Sitemap](${siteUrl.replace(/\/$/, "")}/sitemap.xml)`);
|
|
380
|
+
lines.push("");
|
|
381
|
+
|
|
382
|
+
if (framework) {
|
|
383
|
+
lines.push(`<!-- Framework: ${framework} -->`);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return lines.join("\n");
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function auditMetaTags(cwd: string, targetPath?: string): string {
|
|
390
|
+
const files = targetPath
|
|
391
|
+
? [join(cwd, targetPath)]
|
|
392
|
+
: walkDir(cwd);
|
|
393
|
+
|
|
394
|
+
if (files.length === 0) {
|
|
395
|
+
return "No template files found to audit.";
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const results: string[] = [];
|
|
399
|
+
let totalMissing = 0;
|
|
400
|
+
|
|
401
|
+
for (const file of files.slice(0, 50)) {
|
|
402
|
+
if (!existsSync(file)) continue;
|
|
403
|
+
const content = readFileSync(file, "utf-8");
|
|
404
|
+
const relPath = relative(cwd, file);
|
|
405
|
+
const meta = analyzePage(file, content);
|
|
406
|
+
|
|
407
|
+
const missing: string[] = [];
|
|
408
|
+
if (!meta.hasTitle) missing.push("title");
|
|
409
|
+
if (!meta.hasDescription) missing.push("meta description");
|
|
410
|
+
if (!meta.hasCanonical) missing.push("canonical URL");
|
|
411
|
+
if (!meta.hasOgTitle) missing.push("og:title");
|
|
412
|
+
if (!meta.hasOgDescription) missing.push("og:description");
|
|
413
|
+
if (!meta.hasOgImage) missing.push("og:image");
|
|
414
|
+
if (!meta.hasTwitterCard) missing.push("twitter:card");
|
|
415
|
+
|
|
416
|
+
if (missing.length > 0) {
|
|
417
|
+
totalMissing += missing.length;
|
|
418
|
+
results.push(`### ${relPath}`);
|
|
419
|
+
results.push(`Missing: ${missing.join(", ")}`);
|
|
420
|
+
results.push("");
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (results.length === 0) {
|
|
425
|
+
return "All scanned files have complete meta tags.";
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
return `## Meta Tag Audit\n\n${totalMissing} missing meta tags across ${results.length} files:\n\n${results.join("\n")}`;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// ── Extension ───────────────────────────────────────────────────────────────
|
|
432
|
+
|
|
433
|
+
export default function (elyra: ExtensionAPI): void {
|
|
434
|
+
let cwd = "";
|
|
435
|
+
|
|
436
|
+
elyra.on("session_start", async (_event, ctx) => {
|
|
437
|
+
cwd = ctx.cwd;
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
// ── seo_audit ────────────────────────────────────────────────────────
|
|
441
|
+
|
|
442
|
+
const auditSchema = Type.Object({
|
|
443
|
+
path: Type.Optional(
|
|
444
|
+
Type.String({ description: "Relative path to audit. Omit to audit the entire project." }),
|
|
445
|
+
),
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
elyra.registerTool({
|
|
449
|
+
name: "seo_audit",
|
|
450
|
+
label: "SEO Audit",
|
|
451
|
+
description:
|
|
452
|
+
"Analyze project files for SEO and LLM readability issues. Checks meta tags, headings, structured data, images, OpenGraph, Twitter Cards, and llms.txt. Returns a prioritized list of issues by severity.",
|
|
453
|
+
parameters: auditSchema,
|
|
454
|
+
promptSnippet: "Audit a project for SEO and LLM readability issues",
|
|
455
|
+
async execute(_toolCallId, params) {
|
|
456
|
+
try {
|
|
457
|
+
const targetDir = params.path ? join(cwd, params.path) : cwd;
|
|
458
|
+
const files = walkDir(targetDir);
|
|
459
|
+
|
|
460
|
+
if (files.length === 0) {
|
|
461
|
+
return { content: [{ type: "text", text: "No template files (HTML, JSX, Vue, etc.) found to audit." }] };
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const allIssues: AuditIssue[] = [];
|
|
465
|
+
|
|
466
|
+
// Check for llms.txt
|
|
467
|
+
const llmsTxtPaths = ["public/llms.txt", "static/llms.txt", "llms.txt"];
|
|
468
|
+
const hasLlmsTxt = llmsTxtPaths.some((p) => existsSync(join(cwd, p)));
|
|
469
|
+
if (!hasLlmsTxt) {
|
|
470
|
+
allIssues.push({
|
|
471
|
+
file: "(project root)",
|
|
472
|
+
severity: "low",
|
|
473
|
+
category: "llm",
|
|
474
|
+
message: "No llms.txt found. Generate one with seo_generate_llms_txt for better LLM discoverability.",
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// Audit each file (limit to 50 to avoid overwhelming output)
|
|
479
|
+
for (const file of files.slice(0, 50)) {
|
|
480
|
+
const content = readFileSync(file, "utf-8");
|
|
481
|
+
const relPath = relative(cwd, file);
|
|
482
|
+
const issues = auditPage(file, relPath, content);
|
|
483
|
+
allIssues.push(...issues);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const filesScanned = Math.min(files.length, 50);
|
|
487
|
+
const header = `Scanned ${filesScanned} template file${filesScanned === 1 ? "" : "s"}${files.length > 50 ? ` (${files.length} total, showing first 50)` : ""}.\n\n`;
|
|
488
|
+
const report = header + formatIssues(allIssues);
|
|
489
|
+
|
|
490
|
+
return { content: [{ type: "text", text: report }] };
|
|
491
|
+
} catch (error) {
|
|
492
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
493
|
+
return { content: [{ type: "text", text: `Audit error: ${msg}` }], isError: true };
|
|
494
|
+
}
|
|
495
|
+
},
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
// ── seo_generate_llms_txt ────────────────────────────────────────────
|
|
499
|
+
|
|
500
|
+
const llmsTxtSchema = Type.Object({
|
|
501
|
+
site_url: Type.String({
|
|
502
|
+
description: "The base URL of the site (e.g. https://example.com). Used to construct page links.",
|
|
503
|
+
}),
|
|
504
|
+
output_path: Type.Optional(
|
|
505
|
+
Type.String({
|
|
506
|
+
description: "Output path relative to project root. Default: public/llms.txt",
|
|
507
|
+
}),
|
|
508
|
+
),
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
elyra.registerTool({
|
|
512
|
+
name: "seo_generate_llms_txt",
|
|
513
|
+
label: "Generate llms.txt",
|
|
514
|
+
description:
|
|
515
|
+
"Generate an llms.txt file for LLM discoverability. Scans the project structure to find pages and routes, then creates a formatted llms.txt following the llmstxt.org specification. The generated file should be reviewed and edited by the user before deploying.",
|
|
516
|
+
parameters: llmsTxtSchema,
|
|
517
|
+
promptSnippet: "Generate llms.txt from project structure",
|
|
518
|
+
async execute(_toolCallId, params) {
|
|
519
|
+
try {
|
|
520
|
+
const content = generateLlmsTxt(cwd, params.site_url);
|
|
521
|
+
const outputPath = params.output_path ?? "public/llms.txt";
|
|
522
|
+
|
|
523
|
+
return {
|
|
524
|
+
content: [
|
|
525
|
+
{
|
|
526
|
+
type: "text",
|
|
527
|
+
text: `Generated llms.txt content for ${params.site_url}.\n\nSuggested path: ${outputPath}\n\n---\n\n${content}\n\n---\n\nReview and edit the content above, then write it to ${outputPath} using the write tool. Add descriptions to each page link for better LLM context.`,
|
|
528
|
+
},
|
|
529
|
+
],
|
|
530
|
+
};
|
|
531
|
+
} catch (error) {
|
|
532
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
533
|
+
return { content: [{ type: "text", text: `Generation error: ${msg}` }], isError: true };
|
|
534
|
+
}
|
|
535
|
+
},
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
// ── seo_generate_schema ──────────────────────────────────────────────
|
|
539
|
+
|
|
540
|
+
const schemaSchema = Type.Object({
|
|
541
|
+
file: Type.String({ description: "Relative path to the template file to analyze" }),
|
|
542
|
+
type: Type.Optional(
|
|
543
|
+
Type.String({
|
|
544
|
+
description:
|
|
545
|
+
"Schema.org type to generate (Article, Product, FAQPage, Organization, HowTo, LocalBusiness, Event, SoftwareApplication, BreadcrumbList). If omitted, the tool will suggest an appropriate type.",
|
|
546
|
+
}),
|
|
547
|
+
),
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
elyra.registerTool({
|
|
551
|
+
name: "seo_generate_schema",
|
|
552
|
+
label: "Generate Schema.org",
|
|
553
|
+
description:
|
|
554
|
+
"Analyze a template file and generate appropriate JSON-LD structured data. Returns the JSON-LD script tag to add to the page. If a schema.org type is not specified, suggests one based on the page content.",
|
|
555
|
+
parameters: schemaSchema,
|
|
556
|
+
promptSnippet: "Generate JSON-LD structured data for a page",
|
|
557
|
+
async execute(_toolCallId, params) {
|
|
558
|
+
try {
|
|
559
|
+
const filePath = join(cwd, params.file);
|
|
560
|
+
if (!existsSync(filePath)) {
|
|
561
|
+
return { content: [{ type: "text", text: `File not found: ${params.file}` }], isError: true };
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const content = readFileSync(filePath, "utf-8");
|
|
565
|
+
const lower = content.toLowerCase();
|
|
566
|
+
|
|
567
|
+
// Detect existing JSON-LD
|
|
568
|
+
const existingLd = content.match(/<script\s+type\s*=\s*["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi);
|
|
569
|
+
let existingInfo = "";
|
|
570
|
+
if (existingLd && existingLd.length > 0) {
|
|
571
|
+
existingInfo = `\n\nExisting JSON-LD found (${existingLd.length} block${existingLd.length > 1 ? "s" : ""}):\n${existingLd.join("\n")}`;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// Suggest type if not provided
|
|
575
|
+
let suggestedType = params.type;
|
|
576
|
+
if (!suggestedType) {
|
|
577
|
+
if (/article|blog|post|news/i.test(params.file) || /datePublished|author|article/i.test(content)) {
|
|
578
|
+
suggestedType = "Article";
|
|
579
|
+
} else if (/product|price|buy|shop|cart/i.test(lower)) {
|
|
580
|
+
suggestedType = "Product";
|
|
581
|
+
} else if (/faq|question|answer/i.test(lower) || /<details/i.test(content)) {
|
|
582
|
+
suggestedType = "FAQPage";
|
|
583
|
+
} else if (/about|company|organization|team/i.test(lower)) {
|
|
584
|
+
suggestedType = "Organization";
|
|
585
|
+
} else if (/how.to|tutorial|guide|step/i.test(lower)) {
|
|
586
|
+
suggestedType = "HowTo";
|
|
587
|
+
} else if (/contact|address|phone|location/i.test(lower)) {
|
|
588
|
+
suggestedType = "LocalBusiness";
|
|
589
|
+
} else if (/event|conference|meetup|webinar/i.test(lower)) {
|
|
590
|
+
suggestedType = "Event";
|
|
591
|
+
} else {
|
|
592
|
+
suggestedType = "WebPage";
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// Generate a template
|
|
597
|
+
const templates: Record<string, string> = {
|
|
598
|
+
Article: `{
|
|
599
|
+
"@context": "https://schema.org",
|
|
600
|
+
"@type": "Article",
|
|
601
|
+
"headline": "PAGE TITLE",
|
|
602
|
+
"author": { "@type": "Person", "name": "AUTHOR NAME" },
|
|
603
|
+
"datePublished": "YYYY-MM-DD",
|
|
604
|
+
"dateModified": "YYYY-MM-DD",
|
|
605
|
+
"image": "IMAGE URL",
|
|
606
|
+
"description": "PAGE DESCRIPTION"
|
|
607
|
+
}`,
|
|
608
|
+
Product: `{
|
|
609
|
+
"@context": "https://schema.org",
|
|
610
|
+
"@type": "Product",
|
|
611
|
+
"name": "PRODUCT NAME",
|
|
612
|
+
"description": "PRODUCT DESCRIPTION",
|
|
613
|
+
"image": "IMAGE URL",
|
|
614
|
+
"offers": {
|
|
615
|
+
"@type": "Offer",
|
|
616
|
+
"price": "PRICE",
|
|
617
|
+
"priceCurrency": "USD",
|
|
618
|
+
"availability": "https://schema.org/InStock"
|
|
619
|
+
}
|
|
620
|
+
}`,
|
|
621
|
+
FAQPage: `{
|
|
622
|
+
"@context": "https://schema.org",
|
|
623
|
+
"@type": "FAQPage",
|
|
624
|
+
"mainEntity": [
|
|
625
|
+
{
|
|
626
|
+
"@type": "Question",
|
|
627
|
+
"name": "QUESTION 1",
|
|
628
|
+
"acceptedAnswer": { "@type": "Answer", "text": "ANSWER 1" }
|
|
629
|
+
}
|
|
630
|
+
]
|
|
631
|
+
}`,
|
|
632
|
+
Organization: `{
|
|
633
|
+
"@context": "https://schema.org",
|
|
634
|
+
"@type": "Organization",
|
|
635
|
+
"name": "ORG NAME",
|
|
636
|
+
"url": "SITE URL",
|
|
637
|
+
"logo": "LOGO URL",
|
|
638
|
+
"contactPoint": {
|
|
639
|
+
"@type": "ContactPoint",
|
|
640
|
+
"email": "EMAIL",
|
|
641
|
+
"contactType": "customer service"
|
|
642
|
+
}
|
|
643
|
+
}`,
|
|
644
|
+
HowTo: `{
|
|
645
|
+
"@context": "https://schema.org",
|
|
646
|
+
"@type": "HowTo",
|
|
647
|
+
"name": "HOW-TO TITLE",
|
|
648
|
+
"step": [
|
|
649
|
+
{ "@type": "HowToStep", "name": "Step 1", "text": "STEP DESCRIPTION" }
|
|
650
|
+
]
|
|
651
|
+
}`,
|
|
652
|
+
LocalBusiness: `{
|
|
653
|
+
"@context": "https://schema.org",
|
|
654
|
+
"@type": "LocalBusiness",
|
|
655
|
+
"name": "BUSINESS NAME",
|
|
656
|
+
"address": {
|
|
657
|
+
"@type": "PostalAddress",
|
|
658
|
+
"streetAddress": "STREET",
|
|
659
|
+
"addressLocality": "CITY",
|
|
660
|
+
"addressCountry": "COUNTRY"
|
|
661
|
+
},
|
|
662
|
+
"telephone": "PHONE"
|
|
663
|
+
}`,
|
|
664
|
+
Event: `{
|
|
665
|
+
"@context": "https://schema.org",
|
|
666
|
+
"@type": "Event",
|
|
667
|
+
"name": "EVENT NAME",
|
|
668
|
+
"startDate": "YYYY-MM-DD",
|
|
669
|
+
"location": { "@type": "Place", "name": "VENUE", "address": "ADDRESS" }
|
|
670
|
+
}`,
|
|
671
|
+
SoftwareApplication: `{
|
|
672
|
+
"@context": "https://schema.org",
|
|
673
|
+
"@type": "SoftwareApplication",
|
|
674
|
+
"name": "APP NAME",
|
|
675
|
+
"operatingSystem": "Web",
|
|
676
|
+
"applicationCategory": "CATEGORY",
|
|
677
|
+
"offers": { "@type": "Offer", "price": "0", "priceCurrency": "USD" }
|
|
678
|
+
}`,
|
|
679
|
+
BreadcrumbList: `{
|
|
680
|
+
"@context": "https://schema.org",
|
|
681
|
+
"@type": "BreadcrumbList",
|
|
682
|
+
"itemListElement": [
|
|
683
|
+
{ "@type": "ListItem", "position": 1, "name": "Home", "item": "URL" },
|
|
684
|
+
{ "@type": "ListItem", "position": 2, "name": "PAGE", "item": "URL" }
|
|
685
|
+
]
|
|
686
|
+
}`,
|
|
687
|
+
WebPage: `{
|
|
688
|
+
"@context": "https://schema.org",
|
|
689
|
+
"@type": "WebPage",
|
|
690
|
+
"name": "PAGE TITLE",
|
|
691
|
+
"description": "PAGE DESCRIPTION",
|
|
692
|
+
"url": "PAGE URL"
|
|
693
|
+
}`,
|
|
694
|
+
};
|
|
695
|
+
|
|
696
|
+
const template = templates[suggestedType] ?? templates.WebPage;
|
|
697
|
+
|
|
698
|
+
const output = [
|
|
699
|
+
`Suggested schema.org type for ${params.file}: **${suggestedType}**`,
|
|
700
|
+
existingInfo,
|
|
701
|
+
"",
|
|
702
|
+
"JSON-LD template to add inside `<head>`:",
|
|
703
|
+
"",
|
|
704
|
+
"```html",
|
|
705
|
+
`<script type="application/ld+json">`,
|
|
706
|
+
template,
|
|
707
|
+
"</script>",
|
|
708
|
+
"```",
|
|
709
|
+
"",
|
|
710
|
+
"Replace the UPPERCASE placeholders with actual values from the page content, then add this to the page using the edit tool.",
|
|
711
|
+
].join("\n");
|
|
712
|
+
|
|
713
|
+
return { content: [{ type: "text", text: output }] };
|
|
714
|
+
} catch (error) {
|
|
715
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
716
|
+
return { content: [{ type: "text", text: `Schema generation error: ${msg}` }], isError: true };
|
|
717
|
+
}
|
|
718
|
+
},
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
// ── seo_generate_meta ────────────────────────────────────────────────
|
|
722
|
+
|
|
723
|
+
const metaSchema = Type.Object({
|
|
724
|
+
path: Type.Optional(
|
|
725
|
+
Type.String({ description: "Relative path to a specific file or directory. Omit to audit the entire project." }),
|
|
726
|
+
),
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
elyra.registerTool({
|
|
730
|
+
name: "seo_generate_meta",
|
|
731
|
+
label: "Audit Meta Tags",
|
|
732
|
+
description:
|
|
733
|
+
"Audit template files for missing meta tags (title, description, canonical, OpenGraph, Twitter Cards). Returns a per-file report of missing tags with recommendations.",
|
|
734
|
+
parameters: metaSchema,
|
|
735
|
+
promptSnippet: "Audit and report missing meta tags across the project",
|
|
736
|
+
async execute(_toolCallId, params) {
|
|
737
|
+
try {
|
|
738
|
+
const report = auditMetaTags(cwd, params.path);
|
|
739
|
+
return { content: [{ type: "text", text: report }] };
|
|
740
|
+
} catch (error) {
|
|
741
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
742
|
+
return { content: [{ type: "text", text: `Meta tag audit error: ${msg}` }], isError: true };
|
|
743
|
+
}
|
|
744
|
+
},
|
|
745
|
+
});
|
|
746
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@elyracode/seo",
|
|
3
|
+
"version": "0.7.8",
|
|
4
|
+
"description": "SEO and LLM optimization for Elyra -- audit, structured data, llms.txt, meta tags",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"elyra-package",
|
|
8
|
+
"seo",
|
|
9
|
+
"llm-optimization",
|
|
10
|
+
"structured-data",
|
|
11
|
+
"schema-org"
|
|
12
|
+
],
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"author": "Knut W. Horne",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/kwhorne/elyra.git",
|
|
18
|
+
"directory": "packages/seo"
|
|
19
|
+
},
|
|
20
|
+
"elyra": {
|
|
21
|
+
"extensions": [
|
|
22
|
+
"./extensions/index.ts"
|
|
23
|
+
],
|
|
24
|
+
"skills": [
|
|
25
|
+
"./skills"
|
|
26
|
+
]
|
|
27
|
+
},
|
|
28
|
+
"peerDependencies": {
|
|
29
|
+
"@elyracode/coding-agent": "*",
|
|
30
|
+
"typebox": "*"
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"clean": "echo 'nothing to clean'",
|
|
34
|
+
"build": "echo 'nothing to build'",
|
|
35
|
+
"check": "echo 'nothing to check'"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: elyra-seo
|
|
3
|
+
description: SEO and LLM optimization. Use when the user asks about SEO, meta tags, structured data, schema.org, llms.txt, search engine optimization, or making content discoverable by AI assistants.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# SEO and LLM Optimization
|
|
7
|
+
|
|
8
|
+
## When to Use
|
|
9
|
+
|
|
10
|
+
Use SEO tools when:
|
|
11
|
+
- The user asks to improve SEO or search visibility
|
|
12
|
+
- The project has HTML/JSX/Vue/Blade templates that serve web pages
|
|
13
|
+
- The user asks about llms.txt, structured data, or schema.org
|
|
14
|
+
- The user wants content to be discoverable by AI assistants (ChatGPT, Perplexity, Google AI Overviews)
|
|
15
|
+
- The user asks about meta tags, OpenGraph, or Twitter Cards
|
|
16
|
+
|
|
17
|
+
## Available Tools
|
|
18
|
+
|
|
19
|
+
| Tool | Use when |
|
|
20
|
+
|------|----------|
|
|
21
|
+
| `seo_audit` | Analyzing a project or specific files for SEO and LLM readability issues |
|
|
22
|
+
| `seo_generate_llms_txt` | Creating or updating llms.txt for LLM discoverability |
|
|
23
|
+
| `seo_generate_schema` | Adding or fixing JSON-LD structured data on pages |
|
|
24
|
+
| `seo_generate_meta` | Auditing and generating missing meta tags |
|
|
25
|
+
|
|
26
|
+
## llms.txt Specification
|
|
27
|
+
|
|
28
|
+
The `llms.txt` file lives at the root of a website (like robots.txt) and helps LLMs understand site content.
|
|
29
|
+
|
|
30
|
+
Format:
|
|
31
|
+
```
|
|
32
|
+
# Site Name
|
|
33
|
+
|
|
34
|
+
> Brief description of the site.
|
|
35
|
+
|
|
36
|
+
## Section Name
|
|
37
|
+
- [Page Title](https://example.com/page): Short description
|
|
38
|
+
- [Another Page](https://example.com/other): Short description
|
|
39
|
+
|
|
40
|
+
## Optional
|
|
41
|
+
- [Less important page](https://example.com/extra): Description
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Place at `public/llms.txt` (Next.js, Vite), `static/llms.txt` (Nuxt), or the web root.
|
|
45
|
+
|
|
46
|
+
Optionally create `llms-full.txt` with expanded content for each page.
|
|
47
|
+
|
|
48
|
+
## Schema.org Structured Data
|
|
49
|
+
|
|
50
|
+
Use JSON-LD format in a `<script type="application/ld+json">` tag. Common types:
|
|
51
|
+
|
|
52
|
+
| Page type | Schema.org type | Key properties |
|
|
53
|
+
|-----------|----------------|----------------|
|
|
54
|
+
| Article/blog post | `Article` | headline, author, datePublished, image |
|
|
55
|
+
| Product page | `Product` | name, description, offers, image, review |
|
|
56
|
+
| FAQ page | `FAQPage` | mainEntity (array of Question/Answer) |
|
|
57
|
+
| Organization/about | `Organization` | name, url, logo, contactPoint |
|
|
58
|
+
| How-to guide | `HowTo` | name, step (array of HowToStep) |
|
|
59
|
+
| Local business | `LocalBusiness` | name, address, telephone, openingHours |
|
|
60
|
+
| Event | `Event` | name, startDate, location, offers |
|
|
61
|
+
| Recipe | `Recipe` | name, recipeIngredient, recipeInstructions |
|
|
62
|
+
| Software | `SoftwareApplication` | name, operatingSystem, offers |
|
|
63
|
+
| Breadcrumbs | `BreadcrumbList` | itemListElement (array of ListItem) |
|
|
64
|
+
|
|
65
|
+
Always validate generated JSON-LD with Google's Rich Results Test patterns.
|
|
66
|
+
|
|
67
|
+
## Meta Tags Checklist
|
|
68
|
+
|
|
69
|
+
Every page should have:
|
|
70
|
+
|
|
71
|
+
### Essential
|
|
72
|
+
- `<title>` — 50-60 characters, unique per page
|
|
73
|
+
- `<meta name="description">` — 150-160 characters, unique per page
|
|
74
|
+
- `<meta name="viewport">` — responsive design
|
|
75
|
+
- `<link rel="canonical">` — prevent duplicate content
|
|
76
|
+
|
|
77
|
+
### OpenGraph (social sharing)
|
|
78
|
+
- `og:title` — page title for social
|
|
79
|
+
- `og:description` — description for social
|
|
80
|
+
- `og:image` — preview image (1200x630px recommended)
|
|
81
|
+
- `og:url` — canonical URL
|
|
82
|
+
- `og:type` — website, article, product, etc.
|
|
83
|
+
- `og:site_name` — site name
|
|
84
|
+
|
|
85
|
+
### Twitter Cards
|
|
86
|
+
- `twitter:card` — summary, summary_large_image
|
|
87
|
+
- `twitter:title` — title
|
|
88
|
+
- `twitter:description` — description
|
|
89
|
+
- `twitter:image` — preview image
|
|
90
|
+
|
|
91
|
+
## LLM Readability Patterns
|
|
92
|
+
|
|
93
|
+
Content is more likely to be cited by AI assistants when:
|
|
94
|
+
|
|
95
|
+
1. **Clear heading hierarchy** — H1 > H2 > H3, no skipped levels
|
|
96
|
+
2. **Structured answers** — FAQ sections with `<details>`/`<summary>` or schema.org FAQPage
|
|
97
|
+
3. **Entity definitions** — clear "X is Y" statements early in content
|
|
98
|
+
4. **Citable paragraphs** — self-contained statements that can be extracted as quotes
|
|
99
|
+
5. **Lists and tables** — structured data that LLMs can parse directly
|
|
100
|
+
6. **Consistent terminology** — use the same term for the same concept throughout
|
|
101
|
+
|
|
102
|
+
## Common SEO Issues
|
|
103
|
+
|
|
104
|
+
| Issue | Impact | Fix |
|
|
105
|
+
|-------|--------|-----|
|
|
106
|
+
| Missing `<title>` | Critical | Add unique, descriptive title per page |
|
|
107
|
+
| Missing meta description | High | Add unique description per page |
|
|
108
|
+
| Images without `alt` | Medium | Add descriptive alt text |
|
|
109
|
+
| Missing H1 | High | Add exactly one H1 per page |
|
|
110
|
+
| Multiple H1 tags | Medium | Use only one H1, use H2+ for subheadings |
|
|
111
|
+
| Skipped heading levels | Low | Use sequential heading levels |
|
|
112
|
+
| Missing canonical URL | Medium | Add `<link rel="canonical">` |
|
|
113
|
+
| No structured data | Medium | Add JSON-LD for page type |
|
|
114
|
+
| Missing OpenGraph | Low | Add og: meta tags |
|
|
115
|
+
| No llms.txt | Low | Generate with `seo_generate_llms_txt` |
|
|
116
|
+
|
|
117
|
+
## Framework-Specific Locations
|
|
118
|
+
|
|
119
|
+
| Framework | Meta tags | Structured data | llms.txt |
|
|
120
|
+
|-----------|-----------|-----------------|----------|
|
|
121
|
+
| Next.js (App Router) | `metadata` export or `<head>` in layout | `<script>` in page/layout | `public/llms.txt` |
|
|
122
|
+
| Next.js (Pages Router) | `<Head>` component | `<Head>` with `<script>` | `public/llms.txt` |
|
|
123
|
+
| Nuxt 3 | `useHead()` or `<Head>` | `useHead()` with script | `public/llms.txt` |
|
|
124
|
+
| Laravel/Blade | `@section('meta')` or `<head>` | `<script>` in blade | `public/llms.txt` |
|
|
125
|
+
| Astro | frontmatter + `<head>` | `<script>` in `<head>` | `public/llms.txt` |
|
|
126
|
+
| Vite/SPA | `index.html` `<head>` | `index.html` `<head>` | `public/llms.txt` |
|