@rankcli/agent-runtime 0.0.1 → 0.0.2

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/dist/index.mjs CHANGED
@@ -292,6 +292,15 @@ var ISSUE_DEFINITIONS = {
292
292
  impact: "Poor user experience and potential trust issues.",
293
293
  howToFix: "Update or remove the broken external link."
294
294
  },
295
+ JS_ONLY_NAVIGATION: {
296
+ code: "JS_ONLY_NAVIGATION",
297
+ severity: "warning",
298
+ category: "links",
299
+ title: "JavaScript-only navigation detected",
300
+ description: "Navigation elements use onClick handlers without proper <a href> links.",
301
+ impact: "Search engine crawlers cannot follow JavaScript-only navigation. These links are invisible to crawlers, preventing page discovery and indexing.",
302
+ howToFix: "Replace onClick navigation with proper <a href> or <Link> components. In React, use react-router Link instead of navigate() in onClick handlers. Ensure all navigation renders as real anchor tags with href attributes."
303
+ },
295
304
  TOO_MANY_LINKS: {
296
305
  code: "TOO_MANY_LINKS",
297
306
  severity: "notice",
@@ -2149,6 +2158,7 @@ async function analyzeLinks(html, baseUrl, checkBroken = false) {
2149
2158
  const external = [];
2150
2159
  const brokenInternal = [];
2151
2160
  const brokenExternal = [];
2161
+ const jsOnlyNavigation = [];
2152
2162
  $("a[href]").each((_, el) => {
2153
2163
  const href = $(el).attr("href") || "";
2154
2164
  const text = $(el).text().trim();
@@ -2177,6 +2187,56 @@ async function analyzeLinks(html, baseUrl, checkBroken = false) {
2177
2187
  }
2178
2188
  });
2179
2189
  const totalLinks = internal.length + external.length;
2190
+ const navPatterns = /navigate|router|history\.push|location\.href|window\.location|goto|redirect/i;
2191
+ const navTextPatterns = /^(home|about|contact|pricing|blog|docs|features|products?|services?|sign\s*up|log\s*in|register|dashboard|account|settings|help|faq|support|menu|nav)$/i;
2192
+ $("button[onclick], div[onclick], span[onclick], li[onclick]").each((_, el) => {
2193
+ const $el = $(el);
2194
+ const onclick = $el.attr("onclick") || "";
2195
+ const text = $el.text().trim().substring(0, 50);
2196
+ if (navPatterns.test(onclick) || navTextPatterns.test(text)) {
2197
+ jsOnlyNavigation.push({
2198
+ element: el.tagName.toLowerCase(),
2199
+ text: text || "(no text)",
2200
+ reason: "Element uses onClick for navigation instead of <a href>"
2201
+ });
2202
+ }
2203
+ });
2204
+ $("a[onclick]").each((_, el) => {
2205
+ const $el = $(el);
2206
+ const href = $el.attr("href") || "";
2207
+ const onclick = $el.attr("onclick") || "";
2208
+ const text = $el.text().trim().substring(0, 50);
2209
+ if ((href === "#" || href === "" || href.startsWith("javascript:")) && onclick) {
2210
+ jsOnlyNavigation.push({
2211
+ element: "a",
2212
+ text: text || "(no text)",
2213
+ reason: `Link has href="${href}" with onClick - crawlers cannot follow this`
2214
+ });
2215
+ }
2216
+ });
2217
+ $("[data-href], [data-to], [data-link], [data-route]").each((_, el) => {
2218
+ const $el = $(el);
2219
+ const tagName = el.tagName.toLowerCase();
2220
+ const text = $el.text().trim().substring(0, 50);
2221
+ if (tagName !== "a" || !$el.attr("href") || $el.attr("href") === "#") {
2222
+ jsOnlyNavigation.push({
2223
+ element: tagName,
2224
+ text: text || "(no text)",
2225
+ reason: "Uses data attribute for routing instead of real href"
2226
+ });
2227
+ }
2228
+ });
2229
+ if (jsOnlyNavigation.length > 0) {
2230
+ const uniqueNav = jsOnlyNavigation.slice(0, 10);
2231
+ issues.push({
2232
+ ...ISSUE_DEFINITIONS.JS_ONLY_NAVIGATION,
2233
+ affectedUrls: [baseUrl],
2234
+ details: {
2235
+ count: jsOnlyNavigation.length,
2236
+ examples: uniqueNav
2237
+ }
2238
+ });
2239
+ }
2180
2240
  const internalCount = internal.length;
2181
2241
  const externalCount = external.length;
2182
2242
  const internalToExternalRatio = externalCount > 0 ? internalCount / externalCount : null;
@@ -2284,6 +2344,7 @@ async function analyzeLinks(html, baseUrl, checkBroken = false) {
2284
2344
  totalLinks,
2285
2345
  brokenInternal,
2286
2346
  brokenExternal,
2347
+ jsOnlyNavigation,
2287
2348
  ratio: {
2288
2349
  internal: internalCount,
2289
2350
  external: externalCount,
@@ -7647,6 +7708,12 @@ function analyzeContentScience(html, url, targetKeywords = []) {
7647
7708
  // src/audit/checks/site-maturity.ts
7648
7709
  import * as cheerio28 from "cheerio";
7649
7710
  async function getSSLCertificateAge(url) {
7711
+ let https;
7712
+ try {
7713
+ https = await import("https");
7714
+ } catch {
7715
+ return null;
7716
+ }
7650
7717
  return new Promise((resolve) => {
7651
7718
  try {
7652
7719
  const parsedUrl = new URL(url);
@@ -14159,7 +14226,7 @@ function analyzeStructure(content) {
14159
14226
  import * as cheerio49 from "cheerio";
14160
14227
  async function analyzeHtmlCompliance(html, url, headers) {
14161
14228
  const issues = [];
14162
- const $ = cheerio49.load(html, { decodeEntities: false });
14229
+ const $ = cheerio49.load(html);
14163
14230
  const parsedUrl = new URL(url);
14164
14231
  const doctypeMatch = html.match(/<!DOCTYPE\s+([^>]+)>/i);
14165
14232
  const hasDoctype = doctypeMatch !== null;
@@ -25611,26 +25678,71 @@ async function generateFixForIssue(issue, context) {
25611
25678
  const fullUrl = url || "https://example.com";
25612
25679
  const siteName = new URL(fullUrl).hostname.replace("www.", "");
25613
25680
  switch (issue.code) {
25681
+ // Title issues
25614
25682
  case "MISSING_TITLE":
25683
+ case "TITLE_KEYWORD_MISMATCH":
25684
+ case "TITLE_H1_KEYWORD_MISMATCH":
25685
+ case "OUTDATED_YEAR_IN_TITLE":
25615
25686
  return generateTitleFix(context, siteName);
25687
+ // Meta description issues
25616
25688
  case "MISSING_META_DESC":
25617
25689
  return generateMetaDescFix(context, siteName);
25690
+ // Canonical issues
25618
25691
  case "MISSING_CANONICAL":
25692
+ case "CANONICAL_NO_HTTPS_REDIRECT":
25619
25693
  return generateCanonicalFix(context, fullUrl);
25694
+ // Viewport issues
25620
25695
  case "MISSING_VIEWPORT":
25696
+ case "HTML_NO_VIEWPORT":
25697
+ case "RESPONSIVE_NO_VIEWPORT":
25621
25698
  return generateViewportFix(context);
25699
+ // Open Graph issues
25622
25700
  case "MISSING_OG_TAGS":
25623
25701
  return generateOGFix(context, siteName, fullUrl);
25702
+ // Twitter Card issues
25624
25703
  case "MISSING_TWITTER_CARD":
25625
25704
  return generateTwitterFix(context, siteName);
25705
+ // Schema/structured data issues
25626
25706
  case "MISSING_SCHEMA":
25707
+ case "SCHEMA_ORG_MISSING":
25708
+ case "NO_ORGANIZATION_SCHEMA":
25709
+ case "NO_ENTITY_SCHEMA":
25710
+ case "FAQ_SCHEMA_MISSING":
25627
25711
  return generateSchemaFix(context, siteName, fullUrl);
25712
+ // Robots.txt issues
25628
25713
  case "MISSING_ROBOTS":
25714
+ case "ROBOTS_TXT_WARNINGS":
25715
+ case "ROBOTS_TXT_INVALID_SYNTAX":
25629
25716
  return generateRobotsFix(context, fullUrl);
25717
+ // Sitemap issues
25630
25718
  case "MISSING_SITEMAP":
25719
+ case "BING_SITEMAP_MISSING":
25631
25720
  return generateSitemapFix(context, fullUrl);
25721
+ // H1 issues
25632
25722
  case "MISSING_H1":
25723
+ case "NO_VISIBLE_HEADLINE":
25724
+ case "H1_MISSING_KEYWORD":
25633
25725
  return await generateH1Fix({ cwd });
25726
+ // SPA-specific: add meta management library recommendation
25727
+ case "SPA_NO_META_MANAGEMENT":
25728
+ return generateSPAMetaFix(context, framework);
25729
+ // Preconnect issues
25730
+ case "GOOGLE_FONTS_NO_PRECONNECT":
25731
+ case "MISSING_PRECONNECT":
25732
+ return generatePreconnectFix(context);
25733
+ // Favicon/icons
25734
+ case "HTML_NO_FAVICON":
25735
+ case "HTML_NO_APPLE_TOUCH_ICON":
25736
+ return generateFaviconFix(context);
25737
+ // Charset/lang
25738
+ case "HTML_NO_CHARSET":
25739
+ case "HTML_NOT_UTF8":
25740
+ return generateCharsetFix(context);
25741
+ case "HTML_NO_LANG":
25742
+ return generateLangFix(context);
25743
+ // AI/LLMs.txt
25744
+ case "AI_NO_LLMS_TXT":
25745
+ return generateLlmsTxtFix(context, siteName, fullUrl);
25634
25746
  default:
25635
25747
  return null;
25636
25748
  }
@@ -25859,6 +25971,159 @@ function generateSitemapFix(context, url) {
25859
25971
  explanation: "Created sitemap.xml to help search engines discover all pages"
25860
25972
  };
25861
25973
  }
25974
+ function generateSPAMetaFix(context, framework) {
25975
+ const { htmlPath } = context;
25976
+ if (framework.name.toLowerCase().includes("react") || framework.name === "Unknown") {
25977
+ return {
25978
+ issue: { code: "SPA_NO_META_MANAGEMENT", message: "SPA without dynamic meta tag management", severity: "warning" },
25979
+ file: "src/components/SEOHead.tsx",
25980
+ before: null,
25981
+ after: `import { Helmet } from 'react-helmet-async';
25982
+
25983
+ interface SEOHeadProps {
25984
+ title?: string;
25985
+ description?: string;
25986
+ image?: string;
25987
+ url?: string;
25988
+ }
25989
+
25990
+ export function SEOHead({
25991
+ title = 'Your Site Name',
25992
+ description = 'Your site description',
25993
+ image = '/og-image.png',
25994
+ url = window.location.href,
25995
+ }: SEOHeadProps) {
25996
+ return (
25997
+ <Helmet>
25998
+ <title>{title}</title>
25999
+ <meta name="description" content={description} />
26000
+ <link rel="canonical" href={url} />
26001
+
26002
+ {/* Open Graph */}
26003
+ <meta property="og:title" content={title} />
26004
+ <meta property="og:description" content={description} />
26005
+ <meta property="og:image" content={image} />
26006
+ <meta property="og:url" content={url} />
26007
+ <meta property="og:type" content="website" />
26008
+
26009
+ {/* Twitter */}
26010
+ <meta name="twitter:card" content="summary_large_image" />
26011
+ <meta name="twitter:title" content={title} />
26012
+ <meta name="twitter:description" content={description} />
26013
+ <meta name="twitter:image" content={image} />
26014
+ </Helmet>
26015
+ );
26016
+ }`,
26017
+ explanation: "Created SEOHead component using react-helmet-async for dynamic meta tags. Install: npm install react-helmet-async"
26018
+ };
26019
+ }
26020
+ return {
26021
+ issue: { code: "SPA_NO_META_MANAGEMENT", message: "SPA without meta management", severity: "warning" },
26022
+ file: htmlPath,
26023
+ before: null,
26024
+ after: "<!-- Add a meta management library for your framework -->",
26025
+ explanation: `Add dynamic meta tag management for ${framework.name}`,
26026
+ skipped: true,
26027
+ skipReason: `Framework-specific solution needed for ${framework.name}`
26028
+ };
26029
+ }
26030
+ function generatePreconnectFix(context) {
26031
+ const { htmlPath, htmlContent } = context;
26032
+ const preconnects = `<!-- Preconnect to external origins -->
26033
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
26034
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />`;
26035
+ return {
26036
+ issue: { code: "MISSING_PRECONNECT", message: "Missing preconnect hints", severity: "info" },
26037
+ file: htmlPath,
26038
+ before: "<head>",
26039
+ after: `<head>
26040
+ ${preconnects}`,
26041
+ explanation: "Added preconnect hints to speed up loading of external resources"
26042
+ };
26043
+ }
26044
+ function generateFaviconFix(context) {
26045
+ const { htmlPath } = context;
26046
+ const faviconTags = `<!-- Favicons -->
26047
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
26048
+ <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
26049
+ <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />`;
26050
+ return {
26051
+ issue: { code: "HTML_NO_FAVICON", message: "Missing favicon", severity: "info" },
26052
+ file: htmlPath,
26053
+ before: "<head>",
26054
+ after: `<head>
26055
+ ${faviconTags}`,
26056
+ explanation: "Added favicon links. Create favicon files in public/ directory."
26057
+ };
26058
+ }
26059
+ function generateCharsetFix(context) {
26060
+ const { htmlPath, htmlContent } = context;
26061
+ if (htmlContent.includes("charset")) {
26062
+ return {
26063
+ issue: { code: "HTML_NO_CHARSET", message: "Missing charset", severity: "warning" },
26064
+ file: htmlPath,
26065
+ before: null,
26066
+ after: '<meta charset="UTF-8" />',
26067
+ skipped: true,
26068
+ skipReason: "Charset already defined",
26069
+ explanation: "Charset is already present"
26070
+ };
26071
+ }
26072
+ return {
26073
+ issue: { code: "HTML_NO_CHARSET", message: "Missing charset declaration", severity: "warning" },
26074
+ file: htmlPath,
26075
+ before: "<head>",
26076
+ after: '<head>\n <meta charset="UTF-8" />',
26077
+ explanation: "Added UTF-8 charset declaration as first element in head"
26078
+ };
26079
+ }
26080
+ function generateLangFix(context) {
26081
+ const { htmlPath, htmlContent } = context;
26082
+ if (htmlContent.includes("<html") && !htmlContent.includes("lang=")) {
26083
+ return {
26084
+ issue: { code: "HTML_NO_LANG", message: "Missing lang attribute", severity: "warning" },
26085
+ file: htmlPath,
26086
+ before: "<html>",
26087
+ after: '<html lang="en">',
26088
+ explanation: "Added lang attribute for accessibility and SEO"
26089
+ };
26090
+ }
26091
+ return {
26092
+ issue: { code: "HTML_NO_LANG", message: "Missing lang attribute", severity: "warning" },
26093
+ file: htmlPath,
26094
+ before: "<html",
26095
+ after: '<html lang="en"',
26096
+ explanation: "Added lang attribute for accessibility and SEO"
26097
+ };
26098
+ }
26099
+ function generateLlmsTxtFix(context, siteName, url) {
26100
+ const llmsTxt = `# ${siteName}
26101
+ > ${siteName} - A brief description of your product/service
26102
+
26103
+ ## About
26104
+ ${siteName} is... [Add your description here]
26105
+
26106
+ ## Features
26107
+ - Feature 1
26108
+ - Feature 2
26109
+ - Feature 3
26110
+
26111
+ ## Links
26112
+ - Homepage: ${url}
26113
+ - Documentation: ${url}/docs
26114
+ - API: ${url}/api
26115
+
26116
+ ## Contact
26117
+ - Email: hello@${new URL(url).hostname}
26118
+ `;
26119
+ return {
26120
+ issue: { code: "AI_NO_LLMS_TXT", message: "No llms.txt file for AI crawlers", severity: "info" },
26121
+ file: "public/llms.txt",
26122
+ before: null,
26123
+ after: llmsTxt,
26124
+ explanation: "Created llms.txt to help AI systems understand your site. Customize the content."
26125
+ };
26126
+ }
25862
26127
  async function applyFixes(fixes, options) {
25863
26128
  const { cwd, dryRun = false } = options;
25864
26129
  const applied = [];
@@ -27657,6 +27922,105 @@ function escapeRegex2(str) {
27657
27922
  return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
27658
27923
  }
27659
27924
 
27925
+ // src/geo/llm-citation-checker.ts
27926
+ var PROVIDER_NAMES = {
27927
+ openai: "ChatGPT",
27928
+ anthropic: "Claude",
27929
+ google: "Gemini",
27930
+ perplexity: "Perplexity"
27931
+ };
27932
+ var DEFAULT_PROVIDERS = ["openai", "anthropic", "google", "perplexity"];
27933
+ function generateRecommendationQueries(brand, industry) {
27934
+ const queries = [
27935
+ `What are the best ${brand.toLowerCase()} alternatives?`,
27936
+ `Can you recommend tools similar to ${brand}?`,
27937
+ `What do you think about ${brand}?`
27938
+ ];
27939
+ if (industry) {
27940
+ queries.push(`What are the best ${industry} tools?`);
27941
+ queries.push(`Recommend a good ${industry} solution`);
27942
+ }
27943
+ return queries;
27944
+ }
27945
+ async function checkLLMCitations(brand, domain, options = {}) {
27946
+ const {
27947
+ providers = DEFAULT_PROVIDERS,
27948
+ queries = generateRecommendationQueries(brand),
27949
+ ...trackingOptions
27950
+ } = options;
27951
+ const brandConfig = {
27952
+ brandName: brand,
27953
+ domains: [domain],
27954
+ alternativeNames: [brand.toLowerCase(), brand.toUpperCase()]
27955
+ };
27956
+ const query = queries[0];
27957
+ const results = await trackLLMVisibility(
27958
+ {
27959
+ keyword: query,
27960
+ brand: brandConfig,
27961
+ providers
27962
+ },
27963
+ trackingOptions
27964
+ );
27965
+ return results.map((result) => ({
27966
+ provider: result.provider,
27967
+ providerName: PROVIDER_NAMES[result.provider],
27968
+ mentioned: result.mentioned,
27969
+ position: result.position,
27970
+ sentiment: result.sentiment,
27971
+ context: result.contextSnippet || null,
27972
+ score: result.score,
27973
+ error: result.error
27974
+ }));
27975
+ }
27976
+ function calculateAIVisibilityScore(results) {
27977
+ if (results.length === 0) return 0;
27978
+ const mentionedCount = results.filter((r) => r.mentioned && !r.error).length;
27979
+ const validResults = results.filter((r) => !r.error);
27980
+ if (validResults.length === 0) return 0;
27981
+ let score = mentionedCount / validResults.length * 100;
27982
+ const topPositions = results.filter(
27983
+ (r) => r.mentioned && r.position !== null && r.position <= 3
27984
+ );
27985
+ if (topPositions.length > 0) {
27986
+ score = Math.min(100, score + 10);
27987
+ }
27988
+ const positiveResults = results.filter((r) => r.sentiment === "positive");
27989
+ if (positiveResults.length > mentionedCount / 2) {
27990
+ score = Math.min(100, score + 5);
27991
+ }
27992
+ return Math.round(score);
27993
+ }
27994
+ function getAIVisibilitySummary(results) {
27995
+ const score = calculateAIVisibilityScore(results);
27996
+ const mentionedIn = results.filter((r) => r.mentioned).map((r) => r.providerName);
27997
+ const notMentionedIn = results.filter((r) => !r.mentioned && !r.error).map((r) => r.providerName);
27998
+ const positions = results.filter((r) => r.position !== null).map((r) => r.position);
27999
+ const bestPosition = positions.length > 0 ? Math.min(...positions) : null;
28000
+ const sentiments = results.filter((r) => r.sentiment !== null).map((r) => r.sentiment);
28001
+ let overallSentiment = null;
28002
+ if (sentiments.length > 0) {
28003
+ const positiveCount = sentiments.filter((s) => s === "positive").length;
28004
+ const negativeCount = sentiments.filter((s) => s === "negative").length;
28005
+ if (positiveCount > 0 && negativeCount > 0) {
28006
+ overallSentiment = "mixed";
28007
+ } else if (positiveCount > negativeCount) {
28008
+ overallSentiment = "positive";
28009
+ } else if (negativeCount > positiveCount) {
28010
+ overallSentiment = "negative";
28011
+ } else {
28012
+ overallSentiment = "neutral";
28013
+ }
28014
+ }
28015
+ return {
28016
+ score,
28017
+ mentionedIn,
28018
+ notMentionedIn,
28019
+ bestPosition,
28020
+ overallSentiment
28021
+ };
28022
+ }
28023
+
27660
28024
  // src/frameworks/index.ts
27661
28025
  var frameworks_exports = {};
27662
28026
  __export(frameworks_exports, {
@@ -28411,6 +28775,7 @@ export {
28411
28775
  applyFixes,
28412
28776
  buildGSCApiRequest,
28413
28777
  buildGSCRequest,
28778
+ calculateAIVisibilityScore,
28414
28779
  calculateBM252 as calculateBM25,
28415
28780
  calculateTFIDF as calculateKeywordTFIDF,
28416
28781
  calculateNextRun,
@@ -28425,6 +28790,7 @@ export {
28425
28790
  checkGitHubCLI,
28426
28791
  checkInternalRedirects,
28427
28792
  checkJSRenderingRatio,
28793
+ checkLLMCitations,
28428
28794
  checkLlmsTxt,
28429
28795
  checkMobileResources,
28430
28796
  checkPlaintextEmails,
@@ -28528,6 +28894,7 @@ export {
28528
28894
  generatePDFReport,
28529
28895
  generatePRDescription,
28530
28896
  generateReactHelmetSocialMeta,
28897
+ generateRecommendationQueries,
28531
28898
  generateRemixMeta,
28532
28899
  generateSecretsDoc,
28533
28900
  generateSocialMetaFix,
@@ -28535,6 +28902,7 @@ export {
28535
28902
  generateUncertaintyQuestions,
28536
28903
  generateWizardQuestions,
28537
28904
  generateWorkflow,
28905
+ getAIVisibilitySummary,
28538
28906
  getAutocompleteSuggestions,
28539
28907
  getDateRange,
28540
28908
  getExpandedSuggestions,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rankcli/agent-runtime",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "description": "RankCLI agent runtime - executes SEO audits and fixes with AI",
5
5
  "homepage": "https://rankcli.dev",
6
6
  "main": "dist/index.js",
@@ -145,7 +145,7 @@ export function analyzeColorContrast(
145
145
  failedChecks++;
146
146
 
147
147
  // Create selector for identification
148
- const tagName = (element as cheerio.Element).name;
148
+ const tagName = (element as any).name;
149
149
  const id = $el.attr('id');
150
150
  const className = $el.attr('class')?.split(' ')[0];
151
151
  const selector = id ? `#${id}` : (className ? `${tagName}.${className}` : tagName);
@@ -69,7 +69,7 @@ export function analyzeDomSize(
69
69
  let maxDepth = 0;
70
70
  let deepestPath: string[] = [];
71
71
 
72
- function getDepth(element: cheerio.Cheerio<cheerio.Element>, path: string[]): number {
72
+ function getDepth(element: cheerio.Cheerio<any>, path: string[]): number {
73
73
  const children = element.children();
74
74
  if (children.length === 0) {
75
75
  if (path.length > maxDepth) {
@@ -48,7 +48,7 @@ export async function analyzeHtmlCompliance(
48
48
  headers?: Record<string, string>
49
49
  ): Promise<{ issues: AuditIssue[]; data: HtmlComplianceData }> {
50
50
  const issues: AuditIssue[] = [];
51
- const $ = cheerio.load(html, { decodeEntities: false });
51
+ const $ = cheerio.load(html);
52
52
  const parsedUrl = new URL(url);
53
53
 
54
54
  // === DOCTYPE CHECK ===
@@ -77,7 +77,7 @@ export function analyzeImageDimensions(
77
77
  if (src && !src.includes('data:image/') && !src.includes('placeholder')) {
78
78
  // Get context (parent element for identification)
79
79
  const parent = $img.parent();
80
- const parentTag = parent.length > 0 ? (parent[0] as cheerio.Element).name || '' : '';
80
+ const parentTag = parent.length > 0 ? (parent[0] as any).name || '' : '';
81
81
  const parentClass = parent.attr('class')?.split(' ')[0] || '';
82
82
  const context = parentClass ? `${parentTag}.${parentClass}` : parentTag;
83
83
 
@@ -9,6 +9,7 @@ export interface LinkData {
9
9
  totalLinks: number;
10
10
  brokenInternal: string[];
11
11
  brokenExternal: string[];
12
+ jsOnlyNavigation: { element: string; text: string; reason: string }[];
12
13
  ratio: {
13
14
  internal: number;
14
15
  external: number;
@@ -29,6 +30,7 @@ export async function analyzeLinks(
29
30
  const external: LinkData['external'] = [];
30
31
  const brokenInternal: string[] = [];
31
32
  const brokenExternal: string[] = [];
33
+ const jsOnlyNavigation: LinkData['jsOnlyNavigation'] = [];
32
34
 
33
35
  // Extract all links
34
36
  $('a[href]').each((_, el) => {
@@ -67,6 +69,73 @@ export async function analyzeLinks(
67
69
 
68
70
  const totalLinks = internal.length + external.length;
69
71
 
72
+ // Check for JavaScript-only navigation
73
+ // Look for elements with onClick that appear to be navigation
74
+ const navPatterns = /navigate|router|history\.push|location\.href|window\.location|goto|redirect/i;
75
+ const navTextPatterns = /^(home|about|contact|pricing|blog|docs|features|products?|services?|sign\s*up|log\s*in|register|dashboard|account|settings|help|faq|support|menu|nav)$/i;
76
+
77
+ // Check buttons and other elements with onClick
78
+ $('button[onclick], div[onclick], span[onclick], li[onclick]').each((_, el) => {
79
+ const $el = $(el);
80
+ const onclick = $el.attr('onclick') || '';
81
+ const text = $el.text().trim().substring(0, 50);
82
+
83
+ // Check if onClick contains navigation-like code
84
+ if (navPatterns.test(onclick) || navTextPatterns.test(text)) {
85
+ jsOnlyNavigation.push({
86
+ element: el.tagName.toLowerCase(),
87
+ text: text || '(no text)',
88
+ reason: 'Element uses onClick for navigation instead of <a href>',
89
+ });
90
+ }
91
+ });
92
+
93
+ // Check <a> tags with non-functional href but onClick (common anti-pattern)
94
+ $('a[onclick]').each((_, el) => {
95
+ const $el = $(el);
96
+ const href = $el.attr('href') || '';
97
+ const onclick = $el.attr('onclick') || '';
98
+ const text = $el.text().trim().substring(0, 50);
99
+
100
+ // href="#" or "javascript:" with onClick is a red flag
101
+ if ((href === '#' || href === '' || href.startsWith('javascript:')) && onclick) {
102
+ jsOnlyNavigation.push({
103
+ element: 'a',
104
+ text: text || '(no text)',
105
+ reason: `Link has href="${href}" with onClick - crawlers cannot follow this`,
106
+ });
107
+ }
108
+ });
109
+
110
+ // Check for React/Vue patterns in inline scripts (data-* attributes often used)
111
+ $('[data-href], [data-to], [data-link], [data-route]').each((_, el) => {
112
+ const $el = $(el);
113
+ const tagName = el.tagName.toLowerCase();
114
+ const text = $el.text().trim().substring(0, 50);
115
+
116
+ // If it's not an <a> tag with a real href, it's JS-only navigation
117
+ if (tagName !== 'a' || !$el.attr('href') || $el.attr('href') === '#') {
118
+ jsOnlyNavigation.push({
119
+ element: tagName,
120
+ text: text || '(no text)',
121
+ reason: 'Uses data attribute for routing instead of real href',
122
+ });
123
+ }
124
+ });
125
+
126
+ // Report JS-only navigation issues (deduplicated, limit to 10)
127
+ if (jsOnlyNavigation.length > 0) {
128
+ const uniqueNav = jsOnlyNavigation.slice(0, 10);
129
+ issues.push({
130
+ ...ISSUE_DEFINITIONS.JS_ONLY_NAVIGATION,
131
+ affectedUrls: [baseUrl],
132
+ details: {
133
+ count: jsOnlyNavigation.length,
134
+ examples: uniqueNav,
135
+ },
136
+ });
137
+ }
138
+
70
139
  // Calculate link ratio
71
140
  const internalCount = internal.length;
72
141
  const externalCount = external.length;
@@ -193,6 +262,7 @@ export async function analyzeLinks(
193
262
  totalLinks,
194
263
  brokenInternal,
195
264
  brokenExternal,
265
+ jsOnlyNavigation,
196
266
  ratio: {
197
267
  internal: internalCount,
198
268
  external: externalCount,
@@ -38,6 +38,15 @@ export interface SiteMaturityData {
38
38
  * Estimate domain age from SSL certificate issuance date
39
39
  */
40
40
  export async function getSSLCertificateAge(url: string): Promise<number | null> {
41
+ // Dynamically import https module (Node.js only)
42
+ let https: typeof import('https');
43
+ try {
44
+ https = await import('https');
45
+ } catch {
46
+ // Not available in Deno/browser
47
+ return null;
48
+ }
49
+
41
50
  return new Promise((resolve) => {
42
51
  try {
43
52
  const parsedUrl = new URL(url);