@rankcli/agent-runtime 0.0.1 → 0.0.3

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,104 @@ 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_MISSING":
25684
+ // Full audit code
25685
+ case "TITLE_KEYWORD_MISMATCH":
25686
+ case "TITLE_H1_KEYWORD_MISMATCH":
25687
+ case "OUTDATED_YEAR_IN_TITLE":
25615
25688
  return generateTitleFix(context, siteName);
25689
+ // Meta description issues
25616
25690
  case "MISSING_META_DESC":
25691
+ case "META_DESC_MISSING":
25617
25692
  return generateMetaDescFix(context, siteName);
25693
+ // Canonical issues
25618
25694
  case "MISSING_CANONICAL":
25695
+ case "CANONICAL_NO_HTTPS_REDIRECT":
25619
25696
  return generateCanonicalFix(context, fullUrl);
25697
+ // Viewport issues
25620
25698
  case "MISSING_VIEWPORT":
25699
+ case "HTML_NO_VIEWPORT":
25700
+ case "RESPONSIVE_NO_VIEWPORT":
25621
25701
  return generateViewportFix(context);
25702
+ // Open Graph issues
25622
25703
  case "MISSING_OG_TAGS":
25704
+ case "OG_TITLE_MISSING":
25705
+ case "OG_DESC_MISSING":
25706
+ case "OG_IMAGE_MISSING":
25707
+ case "OG_URL_MISSING":
25623
25708
  return generateOGFix(context, siteName, fullUrl);
25709
+ // Twitter Card issues
25624
25710
  case "MISSING_TWITTER_CARD":
25711
+ case "TWITTER_CARD_MISSING":
25712
+ case "TWITTER_TITLE_MISSING":
25713
+ case "TWITTER_DESC_MISSING":
25714
+ case "TWITTER_IMAGE_MISSING":
25625
25715
  return generateTwitterFix(context, siteName);
25716
+ // Schema/structured data issues
25626
25717
  case "MISSING_SCHEMA":
25718
+ case "SCHEMA_MISSING":
25719
+ // Full audit code
25720
+ case "SCHEMA_ORG_MISSING":
25721
+ case "NO_ORGANIZATION_SCHEMA":
25722
+ case "NO_ENTITY_SCHEMA":
25723
+ case "FAQ_SCHEMA_MISSING":
25627
25724
  return generateSchemaFix(context, siteName, fullUrl);
25725
+ // Robots.txt issues
25628
25726
  case "MISSING_ROBOTS":
25727
+ case "ROBOTS_TXT_MISSING":
25728
+ // Full audit code
25729
+ case "ROBOTS_TXT_WARNINGS":
25730
+ case "ROBOTS_TXT_INVALID_SYNTAX":
25629
25731
  return generateRobotsFix(context, fullUrl);
25732
+ // Sitemap issues
25630
25733
  case "MISSING_SITEMAP":
25734
+ case "SITEMAP_MISSING":
25735
+ // Full audit code
25736
+ case "BING_SITEMAP_MISSING":
25631
25737
  return generateSitemapFix(context, fullUrl);
25738
+ // H1 issues
25632
25739
  case "MISSING_H1":
25740
+ case "NO_VISIBLE_HEADLINE":
25741
+ case "H1_MISSING_KEYWORD":
25633
25742
  return await generateH1Fix({ cwd });
25743
+ // SPA-specific: add meta management library recommendation
25744
+ case "SPA_NO_META_MANAGEMENT":
25745
+ case "SPA_WITHOUT_SSR":
25746
+ case "CLIENT_SIDE_RENDERING":
25747
+ return generateSPAMetaFix(context, framework);
25748
+ // Internal links - suggest adding links
25749
+ case "NO_INTERNAL_LINKS":
25750
+ case "LINKS_NO_INTERNAL":
25751
+ case "NO_CONTENT_INTERNAL_LINKS":
25752
+ return {
25753
+ issue: { code: issue.code, message: "No internal links found", severity: "warning" },
25754
+ file: "src/components/Footer.tsx",
25755
+ before: null,
25756
+ after: `// Add internal navigation links to your footer or content
25757
+ // Example: <Link to="/about">About</Link>`,
25758
+ explanation: "Add internal links to help search engines discover your content",
25759
+ skipped: true,
25760
+ skipReason: "Requires manual content updates - add links to your navigation and content"
25761
+ };
25762
+ // Preconnect issues
25763
+ case "GOOGLE_FONTS_NO_PRECONNECT":
25764
+ case "MISSING_PRECONNECT":
25765
+ return generatePreconnectFix(context);
25766
+ // Favicon/icons
25767
+ case "HTML_NO_FAVICON":
25768
+ case "HTML_NO_APPLE_TOUCH_ICON":
25769
+ return generateFaviconFix(context);
25770
+ // Charset/lang
25771
+ case "HTML_NO_CHARSET":
25772
+ case "HTML_NOT_UTF8":
25773
+ return generateCharsetFix(context);
25774
+ case "HTML_NO_LANG":
25775
+ return generateLangFix(context);
25776
+ // AI/LLMs.txt
25777
+ case "AI_NO_LLMS_TXT":
25778
+ return generateLlmsTxtFix(context, siteName, fullUrl);
25634
25779
  default:
25635
25780
  return null;
25636
25781
  }
@@ -25859,6 +26004,159 @@ function generateSitemapFix(context, url) {
25859
26004
  explanation: "Created sitemap.xml to help search engines discover all pages"
25860
26005
  };
25861
26006
  }
26007
+ function generateSPAMetaFix(context, framework) {
26008
+ const { htmlPath } = context;
26009
+ if (framework.name.toLowerCase().includes("react") || framework.name === "Unknown") {
26010
+ return {
26011
+ issue: { code: "SPA_NO_META_MANAGEMENT", message: "SPA without dynamic meta tag management", severity: "warning" },
26012
+ file: "src/components/SEOHead.tsx",
26013
+ before: null,
26014
+ after: `import { Helmet } from 'react-helmet-async';
26015
+
26016
+ interface SEOHeadProps {
26017
+ title?: string;
26018
+ description?: string;
26019
+ image?: string;
26020
+ url?: string;
26021
+ }
26022
+
26023
+ export function SEOHead({
26024
+ title = 'Your Site Name',
26025
+ description = 'Your site description',
26026
+ image = '/og-image.png',
26027
+ url = window.location.href,
26028
+ }: SEOHeadProps) {
26029
+ return (
26030
+ <Helmet>
26031
+ <title>{title}</title>
26032
+ <meta name="description" content={description} />
26033
+ <link rel="canonical" href={url} />
26034
+
26035
+ {/* Open Graph */}
26036
+ <meta property="og:title" content={title} />
26037
+ <meta property="og:description" content={description} />
26038
+ <meta property="og:image" content={image} />
26039
+ <meta property="og:url" content={url} />
26040
+ <meta property="og:type" content="website" />
26041
+
26042
+ {/* Twitter */}
26043
+ <meta name="twitter:card" content="summary_large_image" />
26044
+ <meta name="twitter:title" content={title} />
26045
+ <meta name="twitter:description" content={description} />
26046
+ <meta name="twitter:image" content={image} />
26047
+ </Helmet>
26048
+ );
26049
+ }`,
26050
+ explanation: "Created SEOHead component using react-helmet-async for dynamic meta tags. Install: npm install react-helmet-async"
26051
+ };
26052
+ }
26053
+ return {
26054
+ issue: { code: "SPA_NO_META_MANAGEMENT", message: "SPA without meta management", severity: "warning" },
26055
+ file: htmlPath,
26056
+ before: null,
26057
+ after: "<!-- Add a meta management library for your framework -->",
26058
+ explanation: `Add dynamic meta tag management for ${framework.name}`,
26059
+ skipped: true,
26060
+ skipReason: `Framework-specific solution needed for ${framework.name}`
26061
+ };
26062
+ }
26063
+ function generatePreconnectFix(context) {
26064
+ const { htmlPath, htmlContent } = context;
26065
+ const preconnects = `<!-- Preconnect to external origins -->
26066
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
26067
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />`;
26068
+ return {
26069
+ issue: { code: "MISSING_PRECONNECT", message: "Missing preconnect hints", severity: "info" },
26070
+ file: htmlPath,
26071
+ before: "<head>",
26072
+ after: `<head>
26073
+ ${preconnects}`,
26074
+ explanation: "Added preconnect hints to speed up loading of external resources"
26075
+ };
26076
+ }
26077
+ function generateFaviconFix(context) {
26078
+ const { htmlPath } = context;
26079
+ const faviconTags = `<!-- Favicons -->
26080
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
26081
+ <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
26082
+ <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />`;
26083
+ return {
26084
+ issue: { code: "HTML_NO_FAVICON", message: "Missing favicon", severity: "info" },
26085
+ file: htmlPath,
26086
+ before: "<head>",
26087
+ after: `<head>
26088
+ ${faviconTags}`,
26089
+ explanation: "Added favicon links. Create favicon files in public/ directory."
26090
+ };
26091
+ }
26092
+ function generateCharsetFix(context) {
26093
+ const { htmlPath, htmlContent } = context;
26094
+ if (htmlContent.includes("charset")) {
26095
+ return {
26096
+ issue: { code: "HTML_NO_CHARSET", message: "Missing charset", severity: "warning" },
26097
+ file: htmlPath,
26098
+ before: null,
26099
+ after: '<meta charset="UTF-8" />',
26100
+ skipped: true,
26101
+ skipReason: "Charset already defined",
26102
+ explanation: "Charset is already present"
26103
+ };
26104
+ }
26105
+ return {
26106
+ issue: { code: "HTML_NO_CHARSET", message: "Missing charset declaration", severity: "warning" },
26107
+ file: htmlPath,
26108
+ before: "<head>",
26109
+ after: '<head>\n <meta charset="UTF-8" />',
26110
+ explanation: "Added UTF-8 charset declaration as first element in head"
26111
+ };
26112
+ }
26113
+ function generateLangFix(context) {
26114
+ const { htmlPath, htmlContent } = context;
26115
+ if (htmlContent.includes("<html") && !htmlContent.includes("lang=")) {
26116
+ return {
26117
+ issue: { code: "HTML_NO_LANG", message: "Missing lang attribute", severity: "warning" },
26118
+ file: htmlPath,
26119
+ before: "<html>",
26120
+ after: '<html lang="en">',
26121
+ explanation: "Added lang attribute for accessibility and SEO"
26122
+ };
26123
+ }
26124
+ return {
26125
+ issue: { code: "HTML_NO_LANG", message: "Missing lang attribute", severity: "warning" },
26126
+ file: htmlPath,
26127
+ before: "<html",
26128
+ after: '<html lang="en"',
26129
+ explanation: "Added lang attribute for accessibility and SEO"
26130
+ };
26131
+ }
26132
+ function generateLlmsTxtFix(context, siteName, url) {
26133
+ const llmsTxt = `# ${siteName}
26134
+ > ${siteName} - A brief description of your product/service
26135
+
26136
+ ## About
26137
+ ${siteName} is... [Add your description here]
26138
+
26139
+ ## Features
26140
+ - Feature 1
26141
+ - Feature 2
26142
+ - Feature 3
26143
+
26144
+ ## Links
26145
+ - Homepage: ${url}
26146
+ - Documentation: ${url}/docs
26147
+ - API: ${url}/api
26148
+
26149
+ ## Contact
26150
+ - Email: hello@${new URL(url).hostname}
26151
+ `;
26152
+ return {
26153
+ issue: { code: "AI_NO_LLMS_TXT", message: "No llms.txt file for AI crawlers", severity: "info" },
26154
+ file: "public/llms.txt",
26155
+ before: null,
26156
+ after: llmsTxt,
26157
+ explanation: "Created llms.txt to help AI systems understand your site. Customize the content."
26158
+ };
26159
+ }
25862
26160
  async function applyFixes(fixes, options) {
25863
26161
  const { cwd, dryRun = false } = options;
25864
26162
  const applied = [];
@@ -27657,6 +27955,105 @@ function escapeRegex2(str) {
27657
27955
  return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
27658
27956
  }
27659
27957
 
27958
+ // src/geo/llm-citation-checker.ts
27959
+ var PROVIDER_NAMES = {
27960
+ openai: "ChatGPT",
27961
+ anthropic: "Claude",
27962
+ google: "Gemini",
27963
+ perplexity: "Perplexity"
27964
+ };
27965
+ var DEFAULT_PROVIDERS = ["openai", "anthropic", "google", "perplexity"];
27966
+ function generateRecommendationQueries(brand, industry) {
27967
+ const queries = [
27968
+ `What are the best ${brand.toLowerCase()} alternatives?`,
27969
+ `Can you recommend tools similar to ${brand}?`,
27970
+ `What do you think about ${brand}?`
27971
+ ];
27972
+ if (industry) {
27973
+ queries.push(`What are the best ${industry} tools?`);
27974
+ queries.push(`Recommend a good ${industry} solution`);
27975
+ }
27976
+ return queries;
27977
+ }
27978
+ async function checkLLMCitations(brand, domain, options = {}) {
27979
+ const {
27980
+ providers = DEFAULT_PROVIDERS,
27981
+ queries = generateRecommendationQueries(brand),
27982
+ ...trackingOptions
27983
+ } = options;
27984
+ const brandConfig = {
27985
+ brandName: brand,
27986
+ domains: [domain],
27987
+ alternativeNames: [brand.toLowerCase(), brand.toUpperCase()]
27988
+ };
27989
+ const query = queries[0];
27990
+ const results = await trackLLMVisibility(
27991
+ {
27992
+ keyword: query,
27993
+ brand: brandConfig,
27994
+ providers
27995
+ },
27996
+ trackingOptions
27997
+ );
27998
+ return results.map((result) => ({
27999
+ provider: result.provider,
28000
+ providerName: PROVIDER_NAMES[result.provider],
28001
+ mentioned: result.mentioned,
28002
+ position: result.position,
28003
+ sentiment: result.sentiment,
28004
+ context: result.contextSnippet || null,
28005
+ score: result.score,
28006
+ error: result.error
28007
+ }));
28008
+ }
28009
+ function calculateAIVisibilityScore(results) {
28010
+ if (results.length === 0) return 0;
28011
+ const mentionedCount = results.filter((r) => r.mentioned && !r.error).length;
28012
+ const validResults = results.filter((r) => !r.error);
28013
+ if (validResults.length === 0) return 0;
28014
+ let score = mentionedCount / validResults.length * 100;
28015
+ const topPositions = results.filter(
28016
+ (r) => r.mentioned && r.position !== null && r.position <= 3
28017
+ );
28018
+ if (topPositions.length > 0) {
28019
+ score = Math.min(100, score + 10);
28020
+ }
28021
+ const positiveResults = results.filter((r) => r.sentiment === "positive");
28022
+ if (positiveResults.length > mentionedCount / 2) {
28023
+ score = Math.min(100, score + 5);
28024
+ }
28025
+ return Math.round(score);
28026
+ }
28027
+ function getAIVisibilitySummary(results) {
28028
+ const score = calculateAIVisibilityScore(results);
28029
+ const mentionedIn = results.filter((r) => r.mentioned).map((r) => r.providerName);
28030
+ const notMentionedIn = results.filter((r) => !r.mentioned && !r.error).map((r) => r.providerName);
28031
+ const positions = results.filter((r) => r.position !== null).map((r) => r.position);
28032
+ const bestPosition = positions.length > 0 ? Math.min(...positions) : null;
28033
+ const sentiments = results.filter((r) => r.sentiment !== null).map((r) => r.sentiment);
28034
+ let overallSentiment = null;
28035
+ if (sentiments.length > 0) {
28036
+ const positiveCount = sentiments.filter((s) => s === "positive").length;
28037
+ const negativeCount = sentiments.filter((s) => s === "negative").length;
28038
+ if (positiveCount > 0 && negativeCount > 0) {
28039
+ overallSentiment = "mixed";
28040
+ } else if (positiveCount > negativeCount) {
28041
+ overallSentiment = "positive";
28042
+ } else if (negativeCount > positiveCount) {
28043
+ overallSentiment = "negative";
28044
+ } else {
28045
+ overallSentiment = "neutral";
28046
+ }
28047
+ }
28048
+ return {
28049
+ score,
28050
+ mentionedIn,
28051
+ notMentionedIn,
28052
+ bestPosition,
28053
+ overallSentiment
28054
+ };
28055
+ }
28056
+
27660
28057
  // src/frameworks/index.ts
27661
28058
  var frameworks_exports = {};
27662
28059
  __export(frameworks_exports, {
@@ -28411,6 +28808,7 @@ export {
28411
28808
  applyFixes,
28412
28809
  buildGSCApiRequest,
28413
28810
  buildGSCRequest,
28811
+ calculateAIVisibilityScore,
28414
28812
  calculateBM252 as calculateBM25,
28415
28813
  calculateTFIDF as calculateKeywordTFIDF,
28416
28814
  calculateNextRun,
@@ -28425,6 +28823,7 @@ export {
28425
28823
  checkGitHubCLI,
28426
28824
  checkInternalRedirects,
28427
28825
  checkJSRenderingRatio,
28826
+ checkLLMCitations,
28428
28827
  checkLlmsTxt,
28429
28828
  checkMobileResources,
28430
28829
  checkPlaintextEmails,
@@ -28528,6 +28927,7 @@ export {
28528
28927
  generatePDFReport,
28529
28928
  generatePRDescription,
28530
28929
  generateReactHelmetSocialMeta,
28930
+ generateRecommendationQueries,
28531
28931
  generateRemixMeta,
28532
28932
  generateSecretsDoc,
28533
28933
  generateSocialMetaFix,
@@ -28535,6 +28935,7 @@ export {
28535
28935
  generateUncertaintyQuestions,
28536
28936
  generateWizardQuestions,
28537
28937
  generateWorkflow,
28938
+ getAIVisibilitySummary,
28538
28939
  getAutocompleteSuggestions,
28539
28940
  getDateRange,
28540
28941
  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.3",
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);