@edtools/cli 0.5.0 → 0.6.1

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.
Files changed (46) hide show
  1. package/dist/chunk-BI3UJPWA.js +690 -0
  2. package/dist/chunk-JCUQ7D56.js +688 -0
  3. package/dist/cli/commands/doctor.d.ts +6 -0
  4. package/dist/cli/commands/doctor.d.ts.map +1 -0
  5. package/dist/cli/commands/doctor.js +150 -0
  6. package/dist/cli/commands/doctor.js.map +1 -0
  7. package/dist/cli/commands/generate.d.ts +2 -0
  8. package/dist/cli/commands/generate.d.ts.map +1 -1
  9. package/dist/cli/commands/generate.js +148 -38
  10. package/dist/cli/commands/generate.js.map +1 -1
  11. package/dist/cli/commands/init.d.ts.map +1 -1
  12. package/dist/cli/commands/init.js +11 -1
  13. package/dist/cli/commands/init.js.map +1 -1
  14. package/dist/cli/commands/validate.d.ts +9 -0
  15. package/dist/cli/commands/validate.d.ts.map +1 -0
  16. package/dist/cli/commands/validate.js +196 -0
  17. package/dist/cli/commands/validate.js.map +1 -0
  18. package/dist/cli/index.js +910 -87
  19. package/dist/cli/index.js.map +1 -1
  20. package/dist/core/generator.d.ts.map +1 -1
  21. package/dist/core/generator.js +36 -8
  22. package/dist/core/generator.js.map +1 -1
  23. package/dist/index.d.ts +64 -1
  24. package/dist/index.js +1 -1
  25. package/dist/types/content.d.ts +44 -0
  26. package/dist/types/content.d.ts.map +1 -1
  27. package/dist/types/hosting.d.ts +19 -0
  28. package/dist/types/hosting.d.ts.map +1 -0
  29. package/dist/types/hosting.js +2 -0
  30. package/dist/types/hosting.js.map +1 -0
  31. package/dist/types/index.d.ts +1 -0
  32. package/dist/types/index.d.ts.map +1 -1
  33. package/dist/types/index.js +1 -0
  34. package/dist/types/index.js.map +1 -1
  35. package/dist/ui/banner.d.ts.map +1 -1
  36. package/dist/ui/banner.js +9 -1
  37. package/dist/ui/banner.js.map +1 -1
  38. package/dist/utils/hosting-detection.d.ts +6 -0
  39. package/dist/utils/hosting-detection.d.ts.map +1 -0
  40. package/dist/utils/hosting-detection.js +173 -0
  41. package/dist/utils/hosting-detection.js.map +1 -0
  42. package/dist/utils/seo-validator.d.ts +35 -0
  43. package/dist/utils/seo-validator.d.ts.map +1 -0
  44. package/dist/utils/seo-validator.js +244 -0
  45. package/dist/utils/seo-validator.js.map +1 -0
  46. package/package.json +4 -3
package/dist/cli/index.js CHANGED
@@ -1,16 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  ContentGenerator
4
- } from "../chunk-AJSCN4WK.js";
4
+ } from "../chunk-BI3UJPWA.js";
5
5
  import "../chunk-INVECVSW.js";
6
6
 
7
7
  // src/cli/index.ts
8
8
  import { Command } from "commander";
9
- import chalk5 from "chalk";
9
+ import chalk7 from "chalk";
10
10
 
11
11
  // src/cli/commands/init.ts
12
- import fs from "fs-extra";
13
- import path from "path";
12
+ import fs2 from "fs-extra";
13
+ import path2 from "path";
14
14
  import chalk2 from "chalk";
15
15
  import ora from "ora";
16
16
  import inquirer from "inquirer";
@@ -49,14 +49,22 @@ function showWelcome() {
49
49
  console.log("");
50
50
  console.log(` ${chalk.cyan("edtools init")} ${chalk.gray("Initialize your project")}`);
51
51
  console.log(` ${chalk.cyan("edtools generate")} ${chalk.gray("Generate SEO-optimized posts")}`);
52
+ console.log(` ${chalk.cyan("edtools validate")} ${chalk.gray("Validate post quality")}`);
52
53
  console.log(` ${chalk.cyan("edtools analyze")} ${chalk.gray("Analyze Google Search Console CSV")}`);
53
54
  console.log("");
55
+ console.log(chalk.bold.cyan(" Advanced Usage:"));
56
+ console.log(chalk.gray(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
57
+ console.log("");
58
+ console.log(` ${chalk.cyan("edtools generate --dry-run")} ${chalk.gray("Preview generation")}`);
59
+ console.log(` ${chalk.cyan("edtools generate --report json")} ${chalk.gray("JSON output")}`);
60
+ console.log(` ${chalk.cyan("edtools validate --threshold 85")} ${chalk.gray("Show posts < 85")}`);
61
+ console.log("");
54
62
  console.log(chalk.gray(" Run ") + chalk.cyan("edtools --help") + chalk.gray(" for all commands"));
55
63
  console.log("");
56
64
  }
57
65
  function getVersion() {
58
66
  try {
59
- return "0.5.0";
67
+ return "0.6.0";
60
68
  } catch {
61
69
  return "dev";
62
70
  }
@@ -96,12 +104,160 @@ function warningBox(message) {
96
104
  });
97
105
  }
98
106
 
107
+ // src/utils/hosting-detection.ts
108
+ import fs from "fs";
109
+ import path from "path";
110
+ import yaml from "yaml";
111
+ var detectors = [
112
+ {
113
+ name: "Firebase",
114
+ file: "firebase.json",
115
+ parse: (content, projectPath) => {
116
+ try {
117
+ const config = JSON.parse(content);
118
+ const publicDir = config.hosting?.public || "public";
119
+ return {
120
+ platform: "Firebase",
121
+ publicDir,
122
+ configFile: "firebase.json"
123
+ };
124
+ } catch (error) {
125
+ return null;
126
+ }
127
+ }
128
+ },
129
+ {
130
+ name: "AWS Amplify",
131
+ file: "amplify.yml",
132
+ parse: (content, projectPath) => {
133
+ try {
134
+ const config = yaml.parse(content);
135
+ const baseDirectory = config?.frontend?.artifacts?.baseDirectory || "build";
136
+ return {
137
+ platform: "AWS Amplify",
138
+ publicDir: baseDirectory,
139
+ configFile: "amplify.yml"
140
+ };
141
+ } catch (error) {
142
+ return null;
143
+ }
144
+ }
145
+ },
146
+ {
147
+ name: "Vercel",
148
+ file: "vercel.json",
149
+ parse: (content, projectPath) => {
150
+ try {
151
+ const config = JSON.parse(content);
152
+ const outputDirectory = config.outputDirectory || config.buildCommand?.match(/out|dist|build/)?.[0] || "public";
153
+ return {
154
+ platform: "Vercel",
155
+ publicDir: outputDirectory,
156
+ configFile: "vercel.json"
157
+ };
158
+ } catch (error) {
159
+ return null;
160
+ }
161
+ }
162
+ },
163
+ {
164
+ name: "Netlify",
165
+ file: "netlify.toml",
166
+ parse: (content, projectPath) => {
167
+ try {
168
+ const match = content.match(/publish\s*=\s*"([^"]+)"/);
169
+ const publicDir = match?.[1] || "public";
170
+ return {
171
+ platform: "Netlify",
172
+ publicDir,
173
+ configFile: "netlify.toml"
174
+ };
175
+ } catch (error) {
176
+ return null;
177
+ }
178
+ }
179
+ },
180
+ {
181
+ name: "Next.js",
182
+ file: "next.config.js",
183
+ parse: (content, projectPath) => {
184
+ if (content.includes("output:") && content.includes("export")) {
185
+ const packageJsonPath = path.join(projectPath, "package.json");
186
+ if (fs.existsSync(packageJsonPath)) {
187
+ const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
188
+ const buildScript = packageJson.scripts?.build || "";
189
+ if (buildScript.includes("out")) {
190
+ return {
191
+ platform: "Next.js (Static Export)",
192
+ publicDir: "out",
193
+ configFile: "next.config.js"
194
+ };
195
+ }
196
+ }
197
+ return {
198
+ platform: "Next.js (Static Export)",
199
+ publicDir: "out",
200
+ configFile: "next.config.js"
201
+ };
202
+ }
203
+ return null;
204
+ }
205
+ }
206
+ ];
207
+ function detectHostingConfig(projectPath) {
208
+ for (const detector of detectors) {
209
+ const configPath = path.join(projectPath, detector.file);
210
+ if (fs.existsSync(configPath)) {
211
+ try {
212
+ const content = fs.readFileSync(configPath, "utf-8");
213
+ const result = detector.parse(content, projectPath);
214
+ if (result) {
215
+ return result;
216
+ }
217
+ } catch (error) {
218
+ console.error(`Error parsing ${detector.file}:`, error);
219
+ }
220
+ }
221
+ }
222
+ return null;
223
+ }
224
+ function validateOutputDir(outputDir, hostingConfig) {
225
+ if (!hostingConfig) {
226
+ return { valid: true };
227
+ }
228
+ const normalizedOutputDir = outputDir.replace(/^\.\//, "");
229
+ const normalizedPublicDir = hostingConfig.publicDir.replace(/^\.\//, "");
230
+ const isInPublicDir = normalizedOutputDir.startsWith(`${normalizedPublicDir}/`) || normalizedOutputDir === normalizedPublicDir;
231
+ const issues = [];
232
+ if (!isInPublicDir) {
233
+ issues.push(
234
+ `Output directory "${outputDir}" is outside the public directory "${hostingConfig.publicDir}"`
235
+ );
236
+ issues.push("Generated files will not be accessible in production");
237
+ }
238
+ return {
239
+ valid: isInPublicDir,
240
+ platform: hostingConfig.platform,
241
+ publicDir: hostingConfig.publicDir,
242
+ currentOutputDir: outputDir,
243
+ suggestion: `./${normalizedPublicDir}/blog`,
244
+ issues: issues.length > 0 ? issues : void 0
245
+ };
246
+ }
247
+ function getSuggestedOutputDir(projectPath) {
248
+ const hostingConfig = detectHostingConfig(projectPath);
249
+ if (hostingConfig) {
250
+ return `./${hostingConfig.publicDir}/blog`;
251
+ }
252
+ return "./blog";
253
+ }
254
+
99
255
  // src/cli/commands/init.ts
100
256
  async function initCommand(options) {
101
257
  console.log(chalk2.cyan.bold("\n\u{1F680} Initializing Edtools...\n"));
102
- const projectPath = path.resolve(options.path);
103
- const configPath = path.join(projectPath, "edtools.config.js");
104
- if (await fs.pathExists(configPath)) {
258
+ const projectPath = path2.resolve(options.path);
259
+ const configPath = path2.join(projectPath, "edtools.config.js");
260
+ if (await fs2.pathExists(configPath)) {
105
261
  const { overwrite } = await inquirer.prompt([
106
262
  {
107
263
  type: "confirm",
@@ -118,9 +274,9 @@ async function initCommand(options) {
118
274
  const spinner = ora("Analyzing your landing page...").start();
119
275
  let productInfo = {};
120
276
  try {
121
- const indexPath = path.join(projectPath, "index.html");
122
- if (await fs.pathExists(indexPath)) {
123
- const html = await fs.readFile(indexPath, "utf-8");
277
+ const indexPath = path2.join(projectPath, "index.html");
278
+ if (await fs2.pathExists(indexPath)) {
279
+ const html = await fs2.readFile(indexPath, "utf-8");
124
280
  const $ = loadCheerio(html);
125
281
  productInfo = {
126
282
  name: $("title").text() || $("h1").first().text() || "My Product",
@@ -240,6 +396,15 @@ async function initCommand(options) {
240
396
  ...productInfo,
241
397
  ...answers
242
398
  };
399
+ const suggestedOutputDir = getSuggestedOutputDir(projectPath);
400
+ const hostingConfig = detectHostingConfig(projectPath);
401
+ if (hostingConfig) {
402
+ console.log("");
403
+ console.log(chalk2.cyan("\u2713 Detected hosting platform: ") + chalk2.white(hostingConfig.platform));
404
+ console.log(chalk2.cyan(" Public directory: ") + chalk2.white(hostingConfig.publicDir));
405
+ console.log(chalk2.cyan(" Suggested blog directory: ") + chalk2.white(suggestedOutputDir));
406
+ console.log("");
407
+ }
243
408
  const config = `module.exports = {
244
409
  product: {
245
410
  name: ${JSON.stringify(finalProductInfo.name)},
@@ -253,7 +418,7 @@ async function initCommand(options) {
253
418
  },
254
419
 
255
420
  content: {
256
- outputDir: './blog',
421
+ outputDir: ${JSON.stringify(suggestedOutputDir)},
257
422
  generateBlog: true,
258
423
  },
259
424
 
@@ -264,17 +429,17 @@ async function initCommand(options) {
264
429
  },
265
430
  };
266
431
  `;
267
- await fs.writeFile(configPath, config, "utf-8");
268
- const edtoolsDir = path.join(projectPath, ".edtools");
269
- await fs.ensureDir(edtoolsDir);
432
+ await fs2.writeFile(configPath, config, "utf-8");
433
+ const edtoolsDir = path2.join(projectPath, ".edtools");
434
+ await fs2.ensureDir(edtoolsDir);
270
435
  const edtoolsConfig = {
271
436
  apiKey: apiKeyAnswer.apiKey,
272
437
  provider: finalProductInfo.preferredProvider
273
438
  };
274
- const edtoolsConfigPath = path.join(edtoolsDir, "config.json");
275
- await fs.writeFile(edtoolsConfigPath, JSON.stringify(edtoolsConfig, null, 2), "utf-8");
276
- const gitignorePath = path.join(edtoolsDir, ".gitignore");
277
- await fs.writeFile(gitignorePath, "*\n!.gitignore\n", "utf-8");
439
+ const edtoolsConfigPath = path2.join(edtoolsDir, "config.json");
440
+ await fs2.writeFile(edtoolsConfigPath, JSON.stringify(edtoolsConfig, null, 2), "utf-8");
441
+ const gitignorePath = path2.join(edtoolsDir, ".gitignore");
442
+ await fs2.writeFile(gitignorePath, "*\n!.gitignore\n", "utf-8");
278
443
  console.log("");
279
444
  console.log(successBox("Configuration created successfully!"));
280
445
  const filesCreated = `${chalk2.cyan("Files created:")}
@@ -294,17 +459,18 @@ ${chalk2.gray("API key stored securely (gitignored)")}`;
294
459
  }
295
460
 
296
461
  // src/cli/commands/generate.ts
297
- import fs2 from "fs-extra";
298
- import path2 from "path";
462
+ import fs3 from "fs-extra";
463
+ import path3 from "path";
299
464
  import { pathToFileURL } from "url";
300
465
  import chalk3 from "chalk";
301
466
  import ora2 from "ora";
302
467
  import Table from "cli-table3";
468
+ import inquirer2 from "inquirer";
303
469
  async function generateCommand(options) {
304
470
  console.log(chalk3.cyan.bold("\n\u{1F4DD} Generating content...\n"));
305
471
  const projectPath = process.cwd();
306
- const configPath = path2.join(projectPath, "edtools.config.js");
307
- if (!await fs2.pathExists(configPath)) {
472
+ const configPath = path3.join(projectPath, "edtools.config.js");
473
+ if (!await fs3.pathExists(configPath)) {
308
474
  console.log(chalk3.red("\u2717 No edtools.config.js found"));
309
475
  console.log(chalk3.yellow(' Run "edtools init" first\n'));
310
476
  process.exit(1);
@@ -318,10 +484,10 @@ async function generateCommand(options) {
318
484
  }
319
485
  const provider = productInfo.preferredProvider || "anthropic";
320
486
  let storedApiKey;
321
- const edtoolsConfigPath = path2.join(projectPath, ".edtools", "config.json");
322
- if (await fs2.pathExists(edtoolsConfigPath)) {
487
+ const edtoolsConfigPath = path3.join(projectPath, ".edtools", "config.json");
488
+ if (await fs3.pathExists(edtoolsConfigPath)) {
323
489
  try {
324
- const edtoolsConfig = await fs2.readJson(edtoolsConfigPath);
490
+ const edtoolsConfig = await fs3.readJson(edtoolsConfigPath);
325
491
  storedApiKey = edtoolsConfig.apiKey;
326
492
  } catch (error) {
327
493
  }
@@ -348,14 +514,14 @@ async function generateCommand(options) {
348
514
  }
349
515
  let topics = options.topics;
350
516
  if (options.fromCsv) {
351
- const opportunitiesPath = path2.join(projectPath, ".edtools", "opportunities.json");
352
- if (!await fs2.pathExists(opportunitiesPath)) {
517
+ const opportunitiesPath = path3.join(projectPath, ".edtools", "opportunities.json");
518
+ if (!await fs3.pathExists(opportunitiesPath)) {
353
519
  console.log(chalk3.red("\u2717 No CSV analysis found"));
354
520
  console.log(chalk3.yellow(' Run "edtools analyze" first to analyze your GSC data\n'));
355
521
  process.exit(1);
356
522
  }
357
523
  try {
358
- const oppData = await fs2.readJson(opportunitiesPath);
524
+ const oppData = await fs3.readJson(opportunitiesPath);
359
525
  topics = oppData.opportunities.slice(0, parseInt(options.posts, 10)).map((opp) => opp.suggestedTitle);
360
526
  console.log(chalk3.cyan("\u{1F4CA} Using topics from CSV analysis:\n"));
361
527
  topics.forEach((topic, i) => {
@@ -376,8 +542,40 @@ async function generateCommand(options) {
376
542
  console.log(chalk3.yellow(`\u26A0\uFE0F Generating ${count} posts at once may trigger spam detection`));
377
543
  console.log(chalk3.yellow(" Recommended: 3-5 posts per week\n"));
378
544
  }
379
- const outputDir = path2.resolve(projectPath, options.output);
380
- await fs2.ensureDir(outputDir);
545
+ const outputDir = path3.resolve(projectPath, options.output);
546
+ await fs3.ensureDir(outputDir);
547
+ const hostingConfig = detectHostingConfig(projectPath);
548
+ if (hostingConfig) {
549
+ const validation = validateOutputDir(options.output, hostingConfig);
550
+ if (!validation.valid) {
551
+ console.log(chalk3.yellow.bold("\n\u26A0\uFE0F HOSTING PLATFORM DETECTED: ") + chalk3.white(validation.platform));
552
+ console.log(chalk3.yellow(" - Public directory: ") + chalk3.white(validation.publicDir));
553
+ console.log(chalk3.yellow(" - Your outputDir: ") + chalk3.white(validation.currentOutputDir));
554
+ console.log(chalk3.yellow(" - Problem: Files outside ") + chalk3.white(validation.publicDir + "/") + chalk3.yellow(" won't be accessible"));
555
+ console.log("");
556
+ console.log(chalk3.red.bold("\u274C ISSUE: Output directory misconfiguration"));
557
+ console.log(chalk3.red(" Generated files will return 404 in production"));
558
+ console.log("");
559
+ console.log(chalk3.cyan.bold("\u{1F4DD} Suggested fix:"));
560
+ console.log(chalk3.cyan(" Update edtools.config.js:"));
561
+ console.log("");
562
+ console.log(chalk3.gray(" content: {"));
563
+ console.log(chalk3.green(` outputDir: '${validation.suggestion}'`) + chalk3.gray(" // \u2705 Correct path"));
564
+ console.log(chalk3.gray(" }"));
565
+ console.log("");
566
+ const answer = await inquirer2.prompt([{
567
+ type: "confirm",
568
+ name: "continue",
569
+ message: "Continue anyway?",
570
+ default: false
571
+ }]);
572
+ if (!answer.continue) {
573
+ console.log(chalk3.yellow("\n\u2717 Generation cancelled\n"));
574
+ process.exit(0);
575
+ }
576
+ console.log("");
577
+ }
578
+ }
381
579
  console.log(chalk3.cyan("Configuration:"));
382
580
  console.log(` Product: ${chalk3.white(productInfo.name)}`);
383
581
  console.log(` Category: ${chalk3.white(productInfo.category)}`);
@@ -395,52 +593,120 @@ async function generateCommand(options) {
395
593
  avoidDuplicates: true,
396
594
  similarityThreshold: 0.85,
397
595
  provider,
398
- apiKey
596
+ apiKey,
597
+ dryRun: options.dryRun
399
598
  };
400
599
  const providerName = provider === "anthropic" ? "Claude" : "ChatGPT";
401
600
  const spinner = ora2(`Generating content with ${providerName}...`).start();
402
601
  try {
403
602
  const result = await generator.generate(generateConfig);
404
603
  if (result.success && result.posts.length > 0) {
405
- spinner.succeed(chalk3.bold("Content generated successfully!"));
604
+ const dryRunMode = result.dryRun || false;
605
+ spinner.succeed(chalk3.bold(dryRunMode ? "Preview generated successfully!" : "Content generated successfully!"));
406
606
  console.log("");
407
- const table = new Table({
408
- head: [
409
- chalk3.cyan.bold("#"),
410
- chalk3.cyan.bold("Title"),
411
- chalk3.cyan.bold("SEO Score"),
412
- chalk3.cyan.bold("Path")
413
- ],
414
- colWidths: [5, 40, 12, 50],
415
- wordWrap: true,
416
- style: {
417
- head: [],
418
- border: ["cyan"]
419
- }
420
- });
421
- result.posts.forEach((post, i) => {
422
- const scoreColor = getScoreColorFn(post.seoScore);
423
- table.push([
424
- chalk3.gray((i + 1).toString()),
425
- chalk3.white(post.title),
426
- scoreColor(`${post.seoScore}/100`),
427
- chalk3.gray(post.path)
428
- ]);
429
- });
430
- console.log(table.toString());
431
- console.log("");
432
- console.log(successBox(`Generated ${result.posts.length} SEO-optimized ${result.posts.length === 1 ? "post" : "posts"}!`));
433
- if (result.warnings && result.warnings.length > 0) {
434
- const warningText = result.warnings.join("\n");
435
- console.log(warningBox(warningText));
607
+ if (dryRunMode) {
608
+ console.log(chalk3.yellow.bold("\u{1F50D} DRY RUN MODE - No files were written\n"));
436
609
  }
437
- console.log(chalk3.cyan.bold("Next steps:"));
438
- console.log(` ${chalk3.cyan("1.")} Review generated content in ${chalk3.white(outputDir)}`);
439
- console.log(` ${chalk3.cyan("2.")} Edit posts to add personal experience/expertise`);
440
- console.log(` ${chalk3.cyan("3.")} Deploy to your website`);
441
- console.log("");
442
- console.log(chalk3.yellow(`\u{1F4A1} Tip: Wait 3-7 days before generating more posts to avoid spam detection
610
+ if (options.report === "json" || options.report === "pretty") {
611
+ const report = {
612
+ success: result.success,
613
+ generated: result.posts.length,
614
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
615
+ files: result.posts.map((post) => ({
616
+ path: post.path,
617
+ slug: post.slug,
618
+ title: post.title,
619
+ url: post.url,
620
+ wordCount: post.wordCount,
621
+ readTime: post.readTime,
622
+ seoScore: post.seoScore,
623
+ issues: post.seoIssues || [],
624
+ metadata: {
625
+ id: post.id,
626
+ tags: post.tags,
627
+ keywords: post.keywords,
628
+ datePublished: post.datePublished
629
+ }
630
+ })),
631
+ manifestPath: result.manifestPath,
632
+ sitemapPath: result.sitemapPath,
633
+ stats: result.stats || {
634
+ avgSeoScore: result.posts.reduce((sum, p) => sum + p.seoScore, 0) / result.posts.length,
635
+ totalWords: result.posts.reduce((sum, p) => sum + p.wordCount, 0),
636
+ avgReadTime: result.posts[0]?.readTime || "0 min"
637
+ },
638
+ warnings: result.warnings || [],
639
+ errors: result.errors || [],
640
+ dryRun: dryRunMode
641
+ };
642
+ const indent = options.report === "pretty" ? 2 : 0;
643
+ console.log(JSON.stringify(report, null, indent));
644
+ } else {
645
+ const table = new Table({
646
+ head: [
647
+ chalk3.cyan.bold("#"),
648
+ chalk3.cyan.bold("Title"),
649
+ chalk3.cyan.bold("Words"),
650
+ chalk3.cyan.bold("SEO"),
651
+ dryRunMode ? chalk3.cyan.bold("Would Create") : chalk3.cyan.bold("Path")
652
+ ],
653
+ colWidths: [5, 35, 10, 10, 45],
654
+ wordWrap: true,
655
+ style: {
656
+ head: [],
657
+ border: ["cyan"]
658
+ }
659
+ });
660
+ result.posts.forEach((post, i) => {
661
+ const scoreColor = getScoreColorFn(post.seoScore);
662
+ table.push([
663
+ chalk3.gray((i + 1).toString()),
664
+ chalk3.white(truncateTitle(post.title, 33)),
665
+ chalk3.white(post.wordCount.toString()),
666
+ scoreColor(`${post.seoScore}/100`),
667
+ chalk3.gray(post.path)
668
+ ]);
669
+ });
670
+ console.log(table.toString());
671
+ console.log("");
672
+ if (result.stats) {
673
+ console.log(chalk3.cyan.bold("Statistics:\n"));
674
+ console.log(` Total words: ${chalk3.white(result.stats.totalWords)}`);
675
+ console.log(` Avg SEO score: ${getScoreColorFn(result.stats.avgSeoScore)(result.stats.avgSeoScore + "/100")}`);
676
+ console.log(` Avg read time: ${chalk3.white(result.stats.avgReadTime)}`);
677
+ console.log("");
678
+ }
679
+ if (dryRunMode) {
680
+ console.log(chalk3.yellow.bold("Would create:\n"));
681
+ if (result.manifestPath) {
682
+ console.log(` ${chalk3.yellow("\u2022")} ${chalk3.white(result.manifestPath)} (manifest with ${result.posts.length} posts)`);
683
+ }
684
+ if (result.sitemapPath) {
685
+ console.log(` ${chalk3.yellow("\u2022")} ${chalk3.white(result.sitemapPath)} (sitemap with ${result.posts.length} URLs)`);
686
+ }
687
+ console.log("");
688
+ }
689
+ if (!dryRunMode) {
690
+ console.log(successBox(`Generated ${result.posts.length} SEO-optimized ${result.posts.length === 1 ? "post" : "posts"}!`));
691
+ }
692
+ if (result.warnings && result.warnings.length > 0) {
693
+ const warningText = result.warnings.join("\n");
694
+ console.log(warningBox(warningText));
695
+ }
696
+ if (dryRunMode) {
697
+ console.log(chalk3.cyan.bold("Next step:"));
698
+ console.log(` ${chalk3.cyan("\u2022")} Run without ${chalk3.white("--dry-run")} to generate files
699
+ `);
700
+ } else {
701
+ console.log(chalk3.cyan.bold("Next steps:"));
702
+ console.log(` ${chalk3.cyan("1.")} Review generated content in ${chalk3.white(outputDir)}`);
703
+ console.log(` ${chalk3.cyan("2.")} Edit posts to add personal experience/expertise`);
704
+ console.log(` ${chalk3.cyan("3.")} Deploy to your website`);
705
+ console.log("");
706
+ console.log(chalk3.yellow(`\u{1F4A1} Tip: Wait 3-7 days before generating more posts to avoid spam detection
443
707
  `));
708
+ }
709
+ }
444
710
  } else {
445
711
  spinner.fail(chalk3.bold("Failed to generate content"));
446
712
  if (result.errors && result.errors.length > 0) {
@@ -456,23 +722,28 @@ async function generateCommand(options) {
456
722
  }
457
723
  }
458
724
  function getScoreColorFn(score) {
459
- if (score >= 80) return chalk3.green;
460
- if (score >= 60) return chalk3.yellow;
725
+ if (score >= 90) return chalk3.green;
726
+ if (score >= 75) return chalk3.yellow;
727
+ if (score >= 60) return chalk3.hex("#FFA500");
461
728
  return chalk3.red;
462
729
  }
730
+ function truncateTitle(title, maxLen) {
731
+ if (title.length <= maxLen) return title;
732
+ return title.substring(0, maxLen - 3) + "...";
733
+ }
463
734
 
464
735
  // src/cli/commands/analyze.ts
465
- import fs4 from "fs-extra";
466
- import path3 from "path";
736
+ import fs5 from "fs-extra";
737
+ import path4 from "path";
467
738
  import chalk4 from "chalk";
468
739
  import ora3 from "ora";
469
740
  import Table2 from "cli-table3";
470
741
 
471
742
  // src/integrations/gsc-csv-analyzer.ts
472
- import fs3 from "fs-extra";
743
+ import fs4 from "fs-extra";
473
744
  import { parse } from "csv-parse/sync";
474
745
  async function parseGSCCSV(filePath) {
475
- const content = await fs3.readFile(filePath, "utf-8");
746
+ const content = await fs4.readFile(filePath, "utf-8");
476
747
  const records = parse(content, {
477
748
  columns: true,
478
749
  skip_empty_lines: true,
@@ -568,7 +839,7 @@ async function saveOpportunities(opportunities, outputPath) {
568
839
  count: opportunities.length,
569
840
  opportunities
570
841
  };
571
- await fs3.writeJson(outputPath, data, { spaces: 2 });
842
+ await fs4.writeJson(outputPath, data, { spaces: 2 });
572
843
  }
573
844
 
574
845
  // src/cli/commands/analyze.ts
@@ -577,9 +848,9 @@ async function analyzeCommand(options) {
577
848
  const projectPath = process.cwd();
578
849
  let csvPath;
579
850
  if (options.file) {
580
- csvPath = path3.resolve(options.file);
851
+ csvPath = path4.resolve(options.file);
581
852
  } else {
582
- const files = await fs4.readdir(projectPath);
853
+ const files = await fs5.readdir(projectPath);
583
854
  const csvFiles = files.filter((f) => f.endsWith(".csv") && f.toLowerCase().includes("search"));
584
855
  if (csvFiles.length === 0) {
585
856
  console.log(chalk4.red("\u2717 No CSV file found"));
@@ -592,9 +863,9 @@ async function analyzeCommand(options) {
592
863
  csvFiles.forEach((f) => console.log(` - ${f}`));
593
864
  console.log(chalk4.yellow("\nUsing first file. Specify with --file to use a different one.\n"));
594
865
  }
595
- csvPath = path3.join(projectPath, csvFiles[0]);
866
+ csvPath = path4.join(projectPath, csvFiles[0]);
596
867
  }
597
- if (!await fs4.pathExists(csvPath)) {
868
+ if (!await fs5.pathExists(csvPath)) {
598
869
  console.log(chalk4.red(`\u2717 File not found: ${csvPath}
599
870
  `));
600
871
  process.exit(1);
@@ -603,7 +874,7 @@ async function analyzeCommand(options) {
603
874
  let data;
604
875
  try {
605
876
  data = await parseGSCCSV(csvPath);
606
- spinner.succeed(`Parsed ${chalk4.white(data.length)} keywords from ${chalk4.white(path3.basename(csvPath))}`);
877
+ spinner.succeed(`Parsed ${chalk4.white(data.length)} keywords from ${chalk4.white(path4.basename(csvPath))}`);
607
878
  } catch (error) {
608
879
  spinner.fail("Failed to parse CSV");
609
880
  console.log(chalk4.red(`
@@ -708,9 +979,9 @@ Error: ${error.message}
708
979
  console.log(topTable.toString());
709
980
  console.log("");
710
981
  }
711
- const edtoolsDir = path3.join(projectPath, ".edtools");
712
- await fs4.ensureDir(edtoolsDir);
713
- const opportunitiesPath = path3.join(edtoolsDir, "opportunities.json");
982
+ const edtoolsDir = path4.join(projectPath, ".edtools");
983
+ await fs5.ensureDir(edtoolsDir);
984
+ const opportunitiesPath = path4.join(edtoolsDir, "opportunities.json");
714
985
  await saveOpportunities(analysis.opportunities, opportunitiesPath);
715
986
  console.log(successBox(`Analysis saved to ${chalk4.white(".edtools/opportunities.json")}`));
716
987
  console.log(chalk4.cyan.bold("Next steps:"));
@@ -720,17 +991,569 @@ Error: ${error.message}
720
991
  console.log(chalk4.gray("\u{1F4A1} Use --from-csv to automatically generate posts for top opportunities\n"));
721
992
  }
722
993
 
994
+ // src/cli/commands/validate.ts
995
+ import fs7 from "fs-extra";
996
+ import path5 from "path";
997
+ import chalk5 from "chalk";
998
+ import Table3 from "cli-table3";
999
+
1000
+ // src/utils/seo-validator.ts
1001
+ import * as cheerio from "cheerio";
1002
+ import fs6 from "fs-extra";
1003
+ async function validatePost(htmlPath) {
1004
+ const html = await fs6.readFile(htmlPath, "utf-8");
1005
+ const $ = cheerio.load(html);
1006
+ const issues = [];
1007
+ const passed = [];
1008
+ const metadata = {};
1009
+ const title = $("title").text().trim();
1010
+ metadata.titleLength = title.length;
1011
+ if (!title) {
1012
+ issues.push({
1013
+ severity: "error",
1014
+ category: "metadata",
1015
+ message: "Missing <title> tag",
1016
+ suggestion: "Add a descriptive title (50-60 characters)"
1017
+ });
1018
+ } else if (title.length < 30) {
1019
+ issues.push({
1020
+ severity: "warning",
1021
+ category: "metadata",
1022
+ message: `Title too short (${title.length} chars)`,
1023
+ suggestion: "Optimal length is 50-60 characters for better CTR"
1024
+ });
1025
+ } else if (title.length > 60) {
1026
+ issues.push({
1027
+ severity: "warning",
1028
+ category: "metadata",
1029
+ message: `Title too long (${title.length} chars)`,
1030
+ suggestion: "Shorten to 50-60 characters to avoid truncation in SERPs"
1031
+ });
1032
+ } else {
1033
+ passed.push(`Title length optimal (${title.length} chars)`);
1034
+ }
1035
+ const metaDesc = $('meta[name="description"]').attr("content")?.trim() || "";
1036
+ metadata.descriptionLength = metaDesc.length;
1037
+ if (!metaDesc) {
1038
+ issues.push({
1039
+ severity: "error",
1040
+ category: "metadata",
1041
+ message: "Missing meta description",
1042
+ suggestion: "Add a compelling description (150-160 characters)"
1043
+ });
1044
+ } else if (metaDesc.length < 120) {
1045
+ issues.push({
1046
+ severity: "warning",
1047
+ category: "metadata",
1048
+ message: `Meta description too short (${metaDesc.length}/160 chars)`,
1049
+ suggestion: `Add ${160 - metaDesc.length} more characters to optimize CTR`
1050
+ });
1051
+ } else if (metaDesc.length > 160) {
1052
+ issues.push({
1053
+ severity: "warning",
1054
+ category: "metadata",
1055
+ message: `Meta description too long (${metaDesc.length}/160 chars)`,
1056
+ suggestion: `Remove ${metaDesc.length - 160} characters to avoid truncation`
1057
+ });
1058
+ } else {
1059
+ passed.push(`Meta description optimal (${metaDesc.length} chars)`);
1060
+ }
1061
+ const h1Elements = $("h1");
1062
+ const h1Count = h1Elements.length;
1063
+ metadata.h1Count = h1Count;
1064
+ if (h1Count === 0) {
1065
+ issues.push({
1066
+ severity: "error",
1067
+ category: "headings",
1068
+ message: "Missing H1 heading",
1069
+ suggestion: "Add a single H1 with primary keyword"
1070
+ });
1071
+ } else if (h1Count > 1) {
1072
+ issues.push({
1073
+ severity: "warning",
1074
+ category: "headings",
1075
+ message: `Multiple H1 tags found (${h1Count})`,
1076
+ suggestion: "Use only one H1 per page for proper structure"
1077
+ });
1078
+ } else {
1079
+ passed.push("Single H1 heading");
1080
+ }
1081
+ const h2Count = $("h2").length;
1082
+ metadata.h2Count = h2Count;
1083
+ if (h2Count < 2) {
1084
+ issues.push({
1085
+ severity: "warning",
1086
+ category: "headings",
1087
+ message: `Few H2 headings for readability (${h2Count} found)`,
1088
+ suggestion: "Add 3-5 H2 headings for better structure and scannability"
1089
+ });
1090
+ } else if (h2Count > 10) {
1091
+ issues.push({
1092
+ severity: "info",
1093
+ category: "headings",
1094
+ message: `Many H2 headings (${h2Count})`,
1095
+ suggestion: "Consider if all sections need H2 or some should be H3"
1096
+ });
1097
+ } else {
1098
+ passed.push(`Good heading structure (${h2Count} H2s)`);
1099
+ }
1100
+ const images = $("img");
1101
+ metadata.imageCount = images.length;
1102
+ const imagesWithoutAlt = images.filter((i, el) => !$(el).attr("alt")).length;
1103
+ if (imagesWithoutAlt > 0) {
1104
+ issues.push({
1105
+ severity: "warning",
1106
+ category: "images",
1107
+ message: `${imagesWithoutAlt} image${imagesWithoutAlt > 1 ? "s" : ""} missing alt text`,
1108
+ suggestion: "Add descriptive alt text to all images for accessibility and SEO"
1109
+ });
1110
+ } else if (images.length > 0) {
1111
+ passed.push(`All images have alt text (${images.length} images)`);
1112
+ }
1113
+ const schemaScript = $('script[type="application/ld+json"]');
1114
+ if (schemaScript.length === 0) {
1115
+ issues.push({
1116
+ severity: "info",
1117
+ category: "structure",
1118
+ message: "Missing Schema.org markup",
1119
+ suggestion: "Add structured data for rich snippets in search results"
1120
+ });
1121
+ } else {
1122
+ passed.push("Has Schema.org markup");
1123
+ }
1124
+ const metaKeywords = $('meta[name="keywords"]');
1125
+ if (metaKeywords.length > 0) {
1126
+ issues.push({
1127
+ severity: "info",
1128
+ category: "metadata",
1129
+ message: "Using deprecated meta keywords tag",
1130
+ suggestion: "Remove meta keywords tag (not used by modern search engines)"
1131
+ });
1132
+ }
1133
+ const ogTitle = $('meta[property="og:title"]').attr("content");
1134
+ const ogDescription = $('meta[property="og:description"]').attr("content");
1135
+ const ogImage = $('meta[property="og:image"]').attr("content");
1136
+ if (!ogTitle || !ogDescription) {
1137
+ issues.push({
1138
+ severity: "info",
1139
+ category: "metadata",
1140
+ message: "Missing Open Graph tags",
1141
+ suggestion: "Add og:title, og:description, og:image for better social sharing"
1142
+ });
1143
+ } else {
1144
+ passed.push("Has Open Graph tags");
1145
+ }
1146
+ const bodyText = $("article, main, body").text();
1147
+ const wordCount = bodyText.replace(/\s+/g, " ").trim().split(/\s+/).filter((word) => word.length > 0).length;
1148
+ metadata.wordCount = wordCount;
1149
+ if (wordCount < 300) {
1150
+ issues.push({
1151
+ severity: "warning",
1152
+ category: "content",
1153
+ message: `Content too short (${wordCount} words)`,
1154
+ suggestion: "Aim for at least 1000 words for better SEO performance"
1155
+ });
1156
+ } else if (wordCount < 1e3) {
1157
+ issues.push({
1158
+ severity: "info",
1159
+ category: "content",
1160
+ message: `Content could be longer (${wordCount} words)`,
1161
+ suggestion: "Consider expanding to 1500+ words for comprehensive coverage"
1162
+ });
1163
+ } else {
1164
+ passed.push(`Good content length (${wordCount} words)`);
1165
+ }
1166
+ const links = $("a[href]");
1167
+ const internalLinks = links.filter((i, el) => {
1168
+ const href = $(el).attr("href") || "";
1169
+ return href.startsWith("/") || href.startsWith("#");
1170
+ }).length;
1171
+ const externalLinks = links.length - internalLinks;
1172
+ if (internalLinks === 0 && links.length > 0) {
1173
+ issues.push({
1174
+ severity: "info",
1175
+ category: "links",
1176
+ message: "No internal links found",
1177
+ suggestion: "Add 2-3 internal links to related content"
1178
+ });
1179
+ } else if (internalLinks > 0) {
1180
+ passed.push(`Has internal links (${internalLinks} links)`);
1181
+ }
1182
+ const errorCount = issues.filter((i) => i.severity === "error").length;
1183
+ const warningCount = issues.filter((i) => i.severity === "warning").length;
1184
+ const infoCount = issues.filter((i) => i.severity === "info").length;
1185
+ let score = 100;
1186
+ score -= errorCount * 20;
1187
+ score -= warningCount * 7;
1188
+ score -= infoCount * 2;
1189
+ score = Math.max(0, Math.min(100, score));
1190
+ return {
1191
+ path: htmlPath,
1192
+ title: title || "Untitled",
1193
+ seoScore: score,
1194
+ issues,
1195
+ passed,
1196
+ metadata
1197
+ };
1198
+ }
1199
+ function calculateValidationStats(results) {
1200
+ const totalPosts = results.length;
1201
+ const avgSeoScore = results.reduce((sum, r) => sum + r.seoScore, 0) / totalPosts;
1202
+ const totalIssues = results.reduce((sum, r) => sum + r.issues.length, 0);
1203
+ const postsWithIssues = results.filter((r) => r.issues.length > 0).length;
1204
+ const issuesBySeverity = {
1205
+ error: results.reduce((sum, r) => sum + r.issues.filter((i) => i.severity === "error").length, 0),
1206
+ warning: results.reduce((sum, r) => sum + r.issues.filter((i) => i.severity === "warning").length, 0),
1207
+ info: results.reduce((sum, r) => sum + r.issues.filter((i) => i.severity === "info").length, 0)
1208
+ };
1209
+ const issuesByCategory = results.reduce((acc, r) => {
1210
+ r.issues.forEach((issue) => {
1211
+ acc[issue.category] = (acc[issue.category] || 0) + 1;
1212
+ });
1213
+ return acc;
1214
+ }, {});
1215
+ return {
1216
+ totalPosts,
1217
+ avgSeoScore: Math.round(avgSeoScore * 10) / 10,
1218
+ totalIssues,
1219
+ postsWithIssues,
1220
+ issuesBySeverity,
1221
+ issuesByCategory
1222
+ };
1223
+ }
1224
+
1225
+ // src/cli/commands/validate.ts
1226
+ async function validateCommand(options) {
1227
+ console.log(chalk5.cyan.bold("\n\u{1F4CA} Validating SEO quality...\n"));
1228
+ const projectPath = process.cwd();
1229
+ let htmlFiles = [];
1230
+ if (options.post) {
1231
+ const postPath = path5.resolve(projectPath, options.post);
1232
+ if (!await fs7.pathExists(postPath)) {
1233
+ console.log(errorBox(`Post not found: ${postPath}`));
1234
+ process.exit(1);
1235
+ }
1236
+ htmlFiles = [postPath];
1237
+ } else if (options.posts) {
1238
+ const postsDir = path5.resolve(projectPath, options.posts);
1239
+ if (!await fs7.pathExists(postsDir)) {
1240
+ console.log(errorBox(`Directory not found: ${postsDir}`));
1241
+ process.exit(1);
1242
+ }
1243
+ htmlFiles = await findHtmlFiles(postsDir);
1244
+ if (htmlFiles.length === 0) {
1245
+ console.log(errorBox(`No HTML files found in: ${postsDir}`));
1246
+ process.exit(1);
1247
+ }
1248
+ } else {
1249
+ const defaultBlogDir = path5.join(projectPath, "blog");
1250
+ if (await fs7.pathExists(defaultBlogDir)) {
1251
+ htmlFiles = await findHtmlFiles(defaultBlogDir);
1252
+ if (htmlFiles.length === 0) {
1253
+ console.log(errorBox("No HTML files found in blog/ directory"));
1254
+ process.exit(1);
1255
+ }
1256
+ } else {
1257
+ console.log(errorBox("No posts directory specified"));
1258
+ console.log(chalk5.yellow("Usage: edtools validate --posts <dir> or --post <file>\n"));
1259
+ process.exit(1);
1260
+ }
1261
+ }
1262
+ console.log(chalk5.cyan(`Found ${htmlFiles.length} post${htmlFiles.length > 1 ? "s" : ""} to validate
1263
+ `));
1264
+ const results = [];
1265
+ for (const htmlFile of htmlFiles) {
1266
+ try {
1267
+ const result = await validatePost(htmlFile);
1268
+ results.push(result);
1269
+ } catch (error) {
1270
+ console.log(chalk5.yellow(`\u26A0\uFE0F Failed to validate ${htmlFile}: ${error.message}`));
1271
+ }
1272
+ }
1273
+ if (results.length === 0) {
1274
+ console.log(errorBox("No posts could be validated"));
1275
+ process.exit(1);
1276
+ }
1277
+ const threshold = options.threshold ? parseInt(options.threshold, 10) : 0;
1278
+ const filteredResults = threshold > 0 ? results.filter((r) => r.seoScore < threshold) : results;
1279
+ if (options.output) {
1280
+ const stats2 = calculateValidationStats(results);
1281
+ const report = {
1282
+ success: true,
1283
+ validated: results.length,
1284
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1285
+ posts: results.map((r) => ({
1286
+ path: r.path,
1287
+ title: r.title,
1288
+ seoScore: r.seoScore,
1289
+ issues: r.issues,
1290
+ passed: r.passed,
1291
+ metadata: r.metadata
1292
+ })),
1293
+ stats: stats2
1294
+ };
1295
+ const outputPath = path5.resolve(projectPath, options.output);
1296
+ await fs7.writeJson(outputPath, report, { spaces: 2 });
1297
+ console.log(successBox(`Validation report saved to ${outputPath}`));
1298
+ }
1299
+ if (filteredResults.length > 0) {
1300
+ const table = new Table3({
1301
+ head: [
1302
+ chalk5.cyan.bold("#"),
1303
+ chalk5.cyan.bold("Title"),
1304
+ chalk5.cyan.bold("Score"),
1305
+ chalk5.cyan.bold("Issues")
1306
+ ],
1307
+ colWidths: [5, 40, 10, 50],
1308
+ wordWrap: true,
1309
+ style: {
1310
+ head: [],
1311
+ border: ["cyan"]
1312
+ }
1313
+ });
1314
+ filteredResults.forEach((result, i) => {
1315
+ const scoreColor = getScoreColor(result.seoScore);
1316
+ const issuesText = result.issues.length > 0 ? result.issues.map((issue) => {
1317
+ const icon = issue.severity === "error" ? "\u2717" : issue.severity === "warning" ? "\u26A0" : "\u2139";
1318
+ const color = issue.severity === "error" ? chalk5.red : issue.severity === "warning" ? chalk5.yellow : chalk5.blue;
1319
+ return color(`${icon} ${issue.message}`);
1320
+ }).join("\n") : chalk5.green("\u2713 No issues");
1321
+ table.push([
1322
+ chalk5.gray((i + 1).toString()),
1323
+ chalk5.white(truncate(result.title, 38)),
1324
+ scoreColor(`${result.seoScore}/100`),
1325
+ issuesText
1326
+ ]);
1327
+ });
1328
+ console.log(table.toString());
1329
+ console.log("");
1330
+ } else if (threshold > 0) {
1331
+ console.log(successBox(`All posts have SEO score >= ${threshold}`));
1332
+ }
1333
+ const stats = calculateValidationStats(results);
1334
+ console.log(chalk5.cyan.bold("Overall Statistics:\n"));
1335
+ console.log(` Total posts validated: ${chalk5.white(stats.totalPosts)}`);
1336
+ console.log(` Average SEO score: ${getScoreColor(stats.avgSeoScore)(stats.avgSeoScore + "/100")}`);
1337
+ console.log(` Posts with issues: ${chalk5.white(stats.postsWithIssues)} (${Math.round(stats.postsWithIssues / stats.totalPosts * 100)}%)`);
1338
+ console.log(` Total issues found: ${chalk5.white(stats.totalIssues)}`);
1339
+ console.log("");
1340
+ if (stats.totalIssues > 0) {
1341
+ console.log(chalk5.cyan.bold("Issues by Severity:\n"));
1342
+ if (stats.issuesBySeverity.error > 0) {
1343
+ console.log(` ${chalk5.red("\u2717 Errors:")} ${chalk5.white(stats.issuesBySeverity.error)}`);
1344
+ }
1345
+ if (stats.issuesBySeverity.warning > 0) {
1346
+ console.log(` ${chalk5.yellow("\u26A0 Warnings:")} ${chalk5.white(stats.issuesBySeverity.warning)}`);
1347
+ }
1348
+ if (stats.issuesBySeverity.info > 0) {
1349
+ console.log(` ${chalk5.blue("\u2139 Info:")} ${chalk5.white(stats.issuesBySeverity.info)}`);
1350
+ }
1351
+ console.log("");
1352
+ }
1353
+ if (Object.keys(stats.issuesByCategory).length > 0) {
1354
+ console.log(chalk5.cyan.bold("Top Issue Categories:\n"));
1355
+ const sortedCategories = Object.entries(stats.issuesByCategory).sort((a, b) => b[1] - a[1]).slice(0, 5);
1356
+ sortedCategories.forEach(([category, count]) => {
1357
+ console.log(` ${chalk5.white(capitalize(category))}: ${chalk5.yellow(count)}`);
1358
+ });
1359
+ console.log("");
1360
+ }
1361
+ if (stats.avgSeoScore < 85) {
1362
+ console.log(chalk5.yellow.bold("\u{1F4A1} Recommendations:\n"));
1363
+ console.log(chalk5.yellow(" \u2022 Focus on fixing critical errors first"));
1364
+ console.log(chalk5.yellow(" \u2022 Optimize meta descriptions to 150-160 characters"));
1365
+ console.log(chalk5.yellow(" \u2022 Ensure all posts have proper heading structure (H1, H2s)"));
1366
+ console.log(chalk5.yellow(" \u2022 Add alt text to all images"));
1367
+ console.log("");
1368
+ } else {
1369
+ console.log(successBox(`Great job! Average SEO score is ${stats.avgSeoScore}/100`));
1370
+ }
1371
+ }
1372
+ async function findHtmlFiles(dir) {
1373
+ const files = [];
1374
+ const entries = await fs7.readdir(dir, { withFileTypes: true });
1375
+ for (const entry of entries) {
1376
+ const fullPath = path5.join(dir, entry.name);
1377
+ if (entry.isDirectory()) {
1378
+ const subFiles = await findHtmlFiles(fullPath);
1379
+ files.push(...subFiles);
1380
+ } else if (entry.isFile() && entry.name.endsWith(".html")) {
1381
+ files.push(fullPath);
1382
+ }
1383
+ }
1384
+ return files;
1385
+ }
1386
+ function getScoreColor(score) {
1387
+ if (score >= 90) return chalk5.green;
1388
+ if (score >= 75) return chalk5.yellow;
1389
+ if (score >= 60) return chalk5.hex("#FFA500");
1390
+ return chalk5.red;
1391
+ }
1392
+ function truncate(str, maxLen) {
1393
+ if (str.length <= maxLen) return str;
1394
+ return str.substring(0, maxLen - 3) + "...";
1395
+ }
1396
+ function capitalize(str) {
1397
+ return str.charAt(0).toUpperCase() + str.slice(1);
1398
+ }
1399
+
1400
+ // src/cli/commands/doctor.ts
1401
+ import fs8 from "fs-extra";
1402
+ import path6 from "path";
1403
+ import { pathToFileURL as pathToFileURL2 } from "url";
1404
+ import chalk6 from "chalk";
1405
+ async function doctorCommand(options) {
1406
+ console.log(chalk6.cyan.bold("\n\u{1F50D} Diagnosing project configuration...\n"));
1407
+ const projectPath = process.cwd();
1408
+ const configPath = path6.join(projectPath, "edtools.config.js");
1409
+ const issues = [];
1410
+ const warnings = [];
1411
+ const suggestions = [];
1412
+ if (await fs8.pathExists(configPath)) {
1413
+ console.log(chalk6.green("\u2713 Configuration file found: ") + chalk6.white("edtools.config.js"));
1414
+ } else {
1415
+ console.log(chalk6.red("\u2717 Configuration file not found"));
1416
+ console.log(chalk6.yellow(' Run "edtools init" to create configuration\n'));
1417
+ process.exit(1);
1418
+ }
1419
+ let productInfo;
1420
+ let outputDir;
1421
+ try {
1422
+ const configUrl = pathToFileURL2(configPath).href;
1423
+ const config = await import(configUrl);
1424
+ productInfo = config.default.product;
1425
+ outputDir = config.default.content?.outputDir || "./blog";
1426
+ if (!productInfo || !productInfo.name) {
1427
+ issues.push("Invalid product configuration in edtools.config.js");
1428
+ }
1429
+ } catch (error) {
1430
+ issues.push(`Failed to load edtools.config.js: ${error.message}`);
1431
+ console.log(chalk6.red("\u2717 Failed to load configuration file"));
1432
+ console.log(chalk6.red(` Error: ${error.message}
1433
+ `));
1434
+ process.exit(1);
1435
+ }
1436
+ const outputDirPath = path6.resolve(projectPath, outputDir);
1437
+ if (await fs8.pathExists(outputDirPath)) {
1438
+ console.log(chalk6.green("\u2713 Output directory exists: ") + chalk6.white(outputDir));
1439
+ } else {
1440
+ warnings.push(`Output directory does not exist: ${outputDir}`);
1441
+ console.log(chalk6.yellow("\u26A0\uFE0F Output directory does not exist: ") + chalk6.white(outputDir));
1442
+ console.log(chalk6.yellow(' It will be created when you run "edtools generate"'));
1443
+ }
1444
+ const edtoolsConfigPath = path6.join(projectPath, ".edtools", "config.json");
1445
+ let hasApiKey = false;
1446
+ if (await fs8.pathExists(edtoolsConfigPath)) {
1447
+ try {
1448
+ const edtoolsConfig = await fs8.readJson(edtoolsConfigPath);
1449
+ hasApiKey = !!edtoolsConfig.apiKey;
1450
+ } catch (error) {
1451
+ }
1452
+ }
1453
+ const hasEnvApiKey = !!(process.env.ANTHROPIC_API_KEY || process.env.OPENAI_API_KEY);
1454
+ if (hasApiKey || hasEnvApiKey) {
1455
+ console.log(chalk6.green("\u2713 API key configured"));
1456
+ } else {
1457
+ warnings.push('No API key found (set via environment variable or "edtools init")');
1458
+ console.log(chalk6.yellow("\u26A0\uFE0F No API key configured"));
1459
+ console.log(chalk6.yellow(" Set ANTHROPIC_API_KEY or OPENAI_API_KEY environment variable"));
1460
+ console.log(chalk6.yellow(' Or run "edtools init" to store API key'));
1461
+ }
1462
+ console.log("");
1463
+ const hostingConfig = detectHostingConfig(projectPath);
1464
+ if (hostingConfig) {
1465
+ console.log(chalk6.cyan("\u26A0\uFE0F HOSTING PLATFORM DETECTED: ") + chalk6.white(hostingConfig.platform));
1466
+ console.log(chalk6.cyan(" - Public directory: ") + chalk6.white(hostingConfig.publicDir));
1467
+ console.log(chalk6.cyan(" - Your outputDir: ") + chalk6.white(outputDir));
1468
+ const validation = validateOutputDir(outputDir, hostingConfig);
1469
+ if (!validation.valid) {
1470
+ issues.push("Output directory misconfiguration");
1471
+ console.log(chalk6.red(" - Problem: Files outside ") + chalk6.white(hostingConfig.publicDir + "/") + chalk6.red(" won't be accessible"));
1472
+ console.log("");
1473
+ console.log(chalk6.red.bold("\u274C ISSUE: Output directory misconfiguration"));
1474
+ console.log(chalk6.red(" Generated files will return 404 in production"));
1475
+ console.log("");
1476
+ console.log(chalk6.cyan.bold("\u{1F4DD} Suggested fix:"));
1477
+ console.log(chalk6.cyan(" Update edtools.config.js:"));
1478
+ console.log("");
1479
+ console.log(chalk6.gray(" content: {"));
1480
+ console.log(chalk6.green(` outputDir: '${validation.suggestion}'`) + chalk6.gray(" // \u2705 Correct path"));
1481
+ console.log(chalk6.gray(" }"));
1482
+ console.log("");
1483
+ suggestions.push(`Update outputDir to '${validation.suggestion}'`);
1484
+ if (options.fix) {
1485
+ console.log(chalk6.yellow.bold("\u{1F527} Auto-fixing configuration...\n"));
1486
+ try {
1487
+ const configContent = await fs8.readFile(configPath, "utf-8");
1488
+ const updatedConfig = configContent.replace(
1489
+ /outputDir:\s*['"]([^'"]+)['"]/,
1490
+ `outputDir: '${validation.suggestion}'`
1491
+ );
1492
+ await fs8.writeFile(configPath, updatedConfig, "utf-8");
1493
+ console.log(chalk6.green("\u2713 Updated edtools.config.js"));
1494
+ console.log(chalk6.green(` outputDir: '${validation.suggestion}'`));
1495
+ console.log("");
1496
+ console.log(chalk6.cyan('\u{1F389} Configuration fixed! Run "edtools generate" to create content.\n'));
1497
+ } catch (error) {
1498
+ console.log(chalk6.red("\u2717 Failed to auto-fix configuration"));
1499
+ console.log(chalk6.red(` Error: ${error.message}`));
1500
+ console.log(chalk6.yellow(" Please update edtools.config.js manually\n"));
1501
+ }
1502
+ } else {
1503
+ console.log(chalk6.cyan.bold("Run: ") + chalk6.white("edtools doctor --fix"));
1504
+ console.log(chalk6.cyan("To automatically apply suggested fixes\n"));
1505
+ }
1506
+ } else {
1507
+ console.log(chalk6.green(" - Status: \u2705 Output directory correctly configured"));
1508
+ console.log("");
1509
+ }
1510
+ } else {
1511
+ console.log(chalk6.gray("No hosting platform configuration detected"));
1512
+ console.log(chalk6.gray("(No firebase.json, vercel.json, netlify.toml, or amplify.yml found)"));
1513
+ console.log("");
1514
+ }
1515
+ console.log(chalk6.cyan.bold("Summary:\n"));
1516
+ if (issues.length === 0 && warnings.length === 0) {
1517
+ console.log(chalk6.green.bold("\u2705 All checks passed! Your project is ready to generate content.\n"));
1518
+ } else {
1519
+ if (issues.length > 0) {
1520
+ console.log(chalk6.red.bold(`\u274C ${issues.length} issue(s) found:`));
1521
+ issues.forEach((issue) => {
1522
+ console.log(chalk6.red(` - ${issue}`));
1523
+ });
1524
+ console.log("");
1525
+ }
1526
+ if (warnings.length > 0) {
1527
+ console.log(chalk6.yellow.bold(`\u26A0\uFE0F ${warnings.length} warning(s):`));
1528
+ warnings.forEach((warning) => {
1529
+ console.log(chalk6.yellow(` - ${warning}`));
1530
+ });
1531
+ console.log("");
1532
+ }
1533
+ if (suggestions.length > 0 && !options.fix) {
1534
+ console.log(chalk6.cyan.bold("\u{1F4A1} Suggestions:"));
1535
+ suggestions.forEach((suggestion) => {
1536
+ console.log(chalk6.cyan(` - ${suggestion}`));
1537
+ });
1538
+ console.log("");
1539
+ console.log(chalk6.cyan("Run ") + chalk6.white("edtools doctor --fix") + chalk6.cyan(" to automatically apply fixes\n"));
1540
+ }
1541
+ }
1542
+ }
1543
+
723
1544
  // src/cli/index.ts
724
1545
  var program = new Command();
725
- program.name("edtools").description("AI-Powered Content Marketing CLI").version("0.5.0");
1546
+ program.name("edtools").description("AI-Powered Content Marketing CLI - Generate, validate, and optimize SEO content").version("0.6.1");
726
1547
  program.command("init").description("Initialize edtools in your project").option("-p, --path <path>", "Project path", process.cwd()).action(initCommand);
727
- program.command("generate").description("Generate blog posts").option("-n, --posts <number>", "Number of posts to generate", "3").option("-t, --topics <topics...>", "Specific topics to write about").option("-o, --output <dir>", "Output directory", "./blog").option("--api-key <key>", "Anthropic API key (or set ANTHROPIC_API_KEY env var)").option("--from-csv", "Generate from CSV analysis opportunities").action(generateCommand);
1548
+ program.command("generate").description("Generate SEO-optimized blog posts").option("-n, --posts <number>", "Number of posts to generate (1-10)", "3").option("-t, --topics <topics...>", "Specific topics to write about").option("-o, --output <dir>", "Output directory", "./blog").option("--api-key <key>", "API key (or use ANTHROPIC_API_KEY/OPENAI_API_KEY env var)").option("--from-csv", "Generate from CSV analysis opportunities").option("--dry-run", "Preview what would be generated without writing files").option("--report <format>", "Output format: json, table, pretty (default: table)", "table").action(generateCommand);
728
1549
  program.command("analyze").description("Analyze Google Search Console CSV data").option("-f, --file <path>", "Path to CSV file (auto-detects if not provided)").option("--min-impressions <number>", "Minimum impressions for opportunities", "50").option("--min-position <number>", "Minimum position for opportunities", "20").option("--limit <number>", "Number of opportunities to show", "10").action(analyzeCommand);
1550
+ program.command("validate").description("Validate SEO quality of existing posts").option("--posts <dir>", "Posts directory to validate").option("--post <file>", "Single post file to validate").option("--output <file>", "Save validation report as JSON").option("--threshold <score>", "Only show posts below this score").action(validateCommand);
1551
+ program.command("doctor").description("Diagnose project configuration and hosting setup").option("--fix", "Automatically fix issues").action(doctorCommand);
729
1552
  program.command("config").description("View or set configuration").option("--set-api-key <key>", "Set Anthropic API key").action(async (options) => {
730
1553
  if (options.setApiKey) {
731
- console.log(chalk5.green("\u2713 API key saved"));
1554
+ console.log(chalk7.green("\u2713 API key saved"));
732
1555
  } else {
733
- console.log(chalk5.cyan("Configuration:"));
1556
+ console.log(chalk7.cyan("Configuration:"));
734
1557
  console.log(` API Key: ${process.env.ANTHROPIC_API_KEY ? "[set]" : "[not set]"}`);
735
1558
  }
736
1559
  });