@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 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` |