@ainyc/canonry 3.4.7 → 3.5.0

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/cli.js CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  isFirstRun,
11
11
  isTelemetryEnabled,
12
12
  listAgentProviders,
13
+ renderReportHtml,
13
14
  reparseStoredResult,
14
15
  reparseStoredResult2,
15
16
  reparseStoredResult3,
@@ -17,7 +18,7 @@ import {
17
18
  setGoogleAuthConfig,
18
19
  showFirstRunNotice,
19
20
  trackEvent
20
- } from "./chunk-3WMODJE5.js";
21
+ } from "./chunk-5G7S6SEP.js";
21
22
  import {
22
23
  CliError,
23
24
  EXIT_SYSTEM_ERROR,
@@ -34,14 +35,11 @@ import {
34
35
  usageError
35
36
  } from "./chunk-ZYESHCMF.js";
36
37
  import {
37
- MIN_TREND_POINTS,
38
38
  apiKeys,
39
39
  competitors,
40
40
  createClient,
41
41
  gaAiReferrals,
42
42
  gaTrafficSnapshots,
43
- groupInsights,
44
- isTrendBaseline,
45
43
  migrate,
46
44
  parseJsonColumn,
47
45
  projects,
@@ -5181,1001 +5179,6 @@ var PROJECT_CLI_COMMANDS = [
5181
5179
  // src/commands/report.ts
5182
5180
  import fs4 from "fs";
5183
5181
  import path3 from "path";
5184
-
5185
- // src/report-renderer.ts
5186
- var COLORS = {
5187
- bg: "#09090b",
5188
- surface: "#18181b4d",
5189
- border: "#27272a99",
5190
- text: "#fafafa",
5191
- textMuted: "#a1a1aa",
5192
- textFaint: "#71717a",
5193
- positive: "#10b981",
5194
- caution: "#f59e0b",
5195
- negative: "#f43f5e",
5196
- neutral: "#71717a",
5197
- accent: "#3b82f6",
5198
- series: ["#10b981", "#3b82f6", "#ec4899", "#eab308", "#a855f7", "#f97316", "#06b6d4", "#ef4444"]
5199
- };
5200
- function escapeHtml(value) {
5201
- return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
5202
- }
5203
- function formatRatio(value) {
5204
- if (!Number.isFinite(value) || value === 0) return "0%";
5205
- return `${(value * 100).toFixed(1)}%`;
5206
- }
5207
- function formatNumber(value) {
5208
- if (!Number.isFinite(value)) return "\u2014";
5209
- if (Math.abs(value) >= 1e6) return `${(value / 1e6).toFixed(1)}M`;
5210
- if (Math.abs(value) >= 1e3) return `${(value / 1e3).toFixed(1)}K`;
5211
- return value.toLocaleString("en-US");
5212
- }
5213
- function summarizeQueryParams(params) {
5214
- const keys = Array.from(params.keys());
5215
- const total = keys.length;
5216
- if (total === 0) return "";
5217
- const noun = total === 1 ? "param" : "params";
5218
- const tag = inferAdSource(params);
5219
- return tag ? `${tag} \xB7 ${total} ${noun}` : `${total} tracking ${noun}`;
5220
- }
5221
- function inferAdSource(params) {
5222
- if (params.has("fbclid")) return "Facebook Ad";
5223
- if (params.has("gclid") || params.has("gbraid") || params.has("wbraid")) return "Google Ad";
5224
- if (params.has("msclkid")) return "Microsoft Ad";
5225
- if (params.has("ttclid")) return "TikTok Ad";
5226
- if (params.has("li_fat_id")) return "LinkedIn Ad";
5227
- if (params.has("twclid")) return "X / Twitter Ad";
5228
- if (params.has("epik")) return "Pinterest Ad";
5229
- for (const k of params.keys()) {
5230
- if (k.startsWith("hsa_")) return "Search Ad";
5231
- }
5232
- const src = params.get("utm_source");
5233
- const med = params.get("utm_medium");
5234
- if (src && med) return `${src} / ${med}`;
5235
- if (src) return `Source: ${src}`;
5236
- if (med) return `Medium: ${med}`;
5237
- return null;
5238
- }
5239
- function formatLandingPageHtml(raw) {
5240
- const value = raw ?? "";
5241
- const queryIdx = value.indexOf("?");
5242
- const path10 = queryIdx === -1 ? value : value.slice(0, queryIdx);
5243
- const query = queryIdx === -1 ? "" : value.slice(queryIdx + 1);
5244
- const pathHtml = `<span class="page-path">${escapeHtml(path10 || "/")}</span>`;
5245
- if (!query) return pathHtml;
5246
- let summary = "";
5247
- try {
5248
- summary = summarizeQueryParams(new URLSearchParams(query));
5249
- } catch {
5250
- summary = "tracking params";
5251
- }
5252
- if (!summary) return pathHtml;
5253
- return `${pathHtml}<span class="page-query" title="${escapeHtml(value)}">${escapeHtml(summary)}</span>`;
5254
- }
5255
- function formatDate(iso) {
5256
- if (!iso) return "\u2014";
5257
- try {
5258
- const d = new Date(iso);
5259
- return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
5260
- } catch {
5261
- return iso;
5262
- }
5263
- }
5264
- function pressureTone(label) {
5265
- if (label === "High") return "negative";
5266
- if (label === "Moderate") return "caution";
5267
- if (label === "Low") return "positive";
5268
- return "neutral";
5269
- }
5270
- function severityTone(severity) {
5271
- switch (severity) {
5272
- case "critical":
5273
- return "negative";
5274
- case "high":
5275
- return "negative";
5276
- case "medium":
5277
- return "caution";
5278
- case "low":
5279
- return "neutral";
5280
- }
5281
- }
5282
- var STYLE = `
5283
- :root {
5284
- color-scheme: dark;
5285
- }
5286
- * { box-sizing: border-box; }
5287
- html, body { margin: 0; padding: 0; }
5288
- body {
5289
- background: ${COLORS.bg};
5290
- color: ${COLORS.text};
5291
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
5292
- font-size: 14px;
5293
- line-height: 1.5;
5294
- -webkit-font-smoothing: antialiased;
5295
- }
5296
- .container {
5297
- max-width: 1100px;
5298
- margin: 0 auto;
5299
- padding: 48px 24px 96px;
5300
- }
5301
- .header {
5302
- border-bottom: 1px solid ${COLORS.border};
5303
- padding-bottom: 32px;
5304
- margin-bottom: 48px;
5305
- }
5306
- .header h1 {
5307
- font-size: 32px;
5308
- font-weight: 700;
5309
- margin: 0 0 8px;
5310
- letter-spacing: -0.02em;
5311
- }
5312
- .header .subtitle {
5313
- color: ${COLORS.textMuted};
5314
- font-size: 14px;
5315
- }
5316
- .eyebrow {
5317
- text-transform: uppercase;
5318
- letter-spacing: 0.08em;
5319
- font-size: 10px;
5320
- color: ${COLORS.textFaint};
5321
- font-weight: 600;
5322
- margin-bottom: 8px;
5323
- }
5324
- section.report-section {
5325
- margin: 64px 0;
5326
- }
5327
- section.report-section h2 {
5328
- font-size: 22px;
5329
- font-weight: 700;
5330
- margin: 0 0 24px;
5331
- letter-spacing: -0.01em;
5332
- }
5333
- section.report-section .section-intro {
5334
- color: ${COLORS.textMuted};
5335
- margin-bottom: 24px;
5336
- }
5337
- .metric-grid {
5338
- display: grid;
5339
- grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
5340
- gap: 16px;
5341
- }
5342
- .metric {
5343
- background: ${COLORS.surface};
5344
- border: 1px solid ${COLORS.border};
5345
- border-radius: 8px;
5346
- padding: 16px 20px;
5347
- }
5348
- .metric .label {
5349
- text-transform: uppercase;
5350
- letter-spacing: 0.08em;
5351
- font-size: 10px;
5352
- color: ${COLORS.textFaint};
5353
- font-weight: 600;
5354
- margin-bottom: 8px;
5355
- }
5356
- .metric .value {
5357
- font-size: 28px;
5358
- font-weight: 700;
5359
- letter-spacing: -0.02em;
5360
- }
5361
- .metric .delta {
5362
- font-size: 12px;
5363
- color: ${COLORS.textMuted};
5364
- margin-top: 4px;
5365
- }
5366
- .findings {
5367
- margin-top: 24px;
5368
- display: grid;
5369
- gap: 12px;
5370
- }
5371
- .finding {
5372
- background: ${COLORS.surface};
5373
- border: 1px solid ${COLORS.border};
5374
- border-left-width: 3px;
5375
- border-radius: 6px;
5376
- padding: 12px 16px;
5377
- }
5378
- .finding.tone-positive { border-left-color: ${COLORS.positive}; }
5379
- .finding.tone-caution { border-left-color: ${COLORS.caution}; }
5380
- .finding.tone-negative { border-left-color: ${COLORS.negative}; }
5381
- .finding.tone-neutral { border-left-color: ${COLORS.neutral}; }
5382
- .finding strong { display: block; margin-bottom: 4px; }
5383
- .finding span { color: ${COLORS.textMuted}; font-size: 13px; }
5384
- table.report-table {
5385
- width: 100%;
5386
- border-collapse: collapse;
5387
- font-size: 13px;
5388
- }
5389
- table.report-table th, table.report-table td {
5390
- text-align: left;
5391
- padding: 10px 12px;
5392
- border-bottom: 1px solid ${COLORS.border};
5393
- vertical-align: top;
5394
- overflow-wrap: anywhere;
5395
- word-break: break-word;
5396
- }
5397
- table.report-table th {
5398
- font-weight: 600;
5399
- color: ${COLORS.textMuted};
5400
- text-transform: uppercase;
5401
- letter-spacing: 0.06em;
5402
- font-size: 10px;
5403
- }
5404
- table.report-table td.numeric { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
5405
- table.report-table td.page-cell { max-width: 0; }
5406
- table.report-table td.page-cell .page-path {
5407
- display: block;
5408
- font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
5409
- font-size: 12px;
5410
- color: ${COLORS.text};
5411
- }
5412
- table.report-table td.page-cell .page-query {
5413
- display: inline-block;
5414
- margin-top: 4px;
5415
- padding: 1px 8px;
5416
- font-size: 11px;
5417
- color: ${COLORS.textMuted};
5418
- background: ${COLORS.surface};
5419
- border: 1px solid ${COLORS.border};
5420
- border-radius: 999px;
5421
- cursor: help;
5422
- }
5423
- table.report-table td .badge {
5424
- display: inline-block;
5425
- padding: 2px 8px;
5426
- border-radius: 999px;
5427
- font-size: 11px;
5428
- font-weight: 600;
5429
- border: 1px solid;
5430
- }
5431
- .cell-cited { color: ${COLORS.positive}; font-weight: 600; }
5432
- .cell-not-cited { color: ${COLORS.textFaint}; }
5433
- .cell-pending { color: ${COLORS.textFaint}; font-style: italic; }
5434
- .tone-positive { color: ${COLORS.positive}; }
5435
- .tone-caution { color: ${COLORS.caution}; }
5436
- .tone-negative { color: ${COLORS.negative}; }
5437
- .tone-neutral { color: ${COLORS.neutral}; }
5438
- .badge.tone-positive { color: ${COLORS.positive}; border-color: ${COLORS.positive}40; background: ${COLORS.positive}14; }
5439
- .badge.tone-caution { color: ${COLORS.caution}; border-color: ${COLORS.caution}40; background: ${COLORS.caution}14; }
5440
- .badge.tone-negative { color: ${COLORS.negative}; border-color: ${COLORS.negative}40; background: ${COLORS.negative}14; }
5441
- .badge.tone-neutral { color: ${COLORS.textMuted}; border-color: ${COLORS.border}; background: transparent; }
5442
- .chart-card {
5443
- background: ${COLORS.surface};
5444
- border: 1px solid ${COLORS.border};
5445
- border-radius: 8px;
5446
- padding: 20px;
5447
- margin-bottom: 16px;
5448
- }
5449
- .chart-card h3 {
5450
- font-size: 14px;
5451
- font-weight: 600;
5452
- margin: 0 0 16px;
5453
- }
5454
- .chart-grid {
5455
- display: grid;
5456
- grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
5457
- gap: 16px;
5458
- }
5459
- .legend {
5460
- display: flex;
5461
- flex-wrap: wrap;
5462
- gap: 12px;
5463
- font-size: 12px;
5464
- margin-top: 12px;
5465
- }
5466
- .legend-swatch {
5467
- display: inline-block;
5468
- width: 10px;
5469
- height: 10px;
5470
- border-radius: 2px;
5471
- margin-right: 6px;
5472
- vertical-align: middle;
5473
- }
5474
- .empty-state {
5475
- background: ${COLORS.surface};
5476
- border: 1px dashed ${COLORS.border};
5477
- border-radius: 8px;
5478
- padding: 32px;
5479
- color: ${COLORS.textMuted};
5480
- text-align: center;
5481
- font-size: 13px;
5482
- }
5483
- .steps {
5484
- display: grid;
5485
- gap: 12px;
5486
- }
5487
- .step {
5488
- background: ${COLORS.surface};
5489
- border: 1px solid ${COLORS.border};
5490
- border-radius: 8px;
5491
- padding: 16px 20px;
5492
- display: grid;
5493
- gap: 4px;
5494
- }
5495
- .step .horizon {
5496
- text-transform: uppercase;
5497
- font-size: 10px;
5498
- letter-spacing: 0.08em;
5499
- color: ${COLORS.textFaint};
5500
- font-weight: 600;
5501
- }
5502
- .step .title { font-weight: 600; }
5503
- .step .rationale { color: ${COLORS.textMuted}; font-size: 13px; }
5504
- .footer {
5505
- margin-top: 96px;
5506
- padding-top: 24px;
5507
- border-top: 1px solid ${COLORS.border};
5508
- text-align: center;
5509
- color: ${COLORS.textFaint};
5510
- font-size: 12px;
5511
- }
5512
- @media print {
5513
- body { background: white; color: black; }
5514
- section.report-section { break-inside: avoid; }
5515
- }
5516
- `;
5517
- function section(opts, body) {
5518
- return `<section class="report-section" id="${escapeHtml(opts.id)}">
5519
- <div class="eyebrow">${escapeHtml(opts.eyebrow)}</div>
5520
- <h2>${escapeHtml(opts.title)}</h2>
5521
- ${opts.intro ? `<p class="section-intro">${escapeHtml(opts.intro)}</p>` : ""}
5522
- ${body}
5523
- </section>`;
5524
- }
5525
- function renderEmpty(message) {
5526
- return `<div class="empty-state">${escapeHtml(message)}</div>`;
5527
- }
5528
- function renderExecutiveSummary(report) {
5529
- const s = report.executiveSummary;
5530
- const trendLabel = s.trend === "up" ? "\u2191 Up" : s.trend === "down" ? "\u2193 Down" : s.trend === "flat" ? "\u2192 Flat" : "\u2014";
5531
- const trendTone = s.trend === "up" ? "positive" : s.trend === "down" ? "negative" : "neutral";
5532
- const metrics = [
5533
- {
5534
- label: "Citation rate",
5535
- value: `${s.citationRate}%`,
5536
- delta: `<span class="tone-${trendTone}">${trendLabel}</span> \xB7 ${s.providerCount} provider${s.providerCount === 1 ? "" : "s"}`
5537
- },
5538
- {
5539
- label: "Keywords tracked",
5540
- value: formatNumber(s.keywordCount),
5541
- delta: `${s.competitorCount} competitor${s.competitorCount === 1 ? "" : "s"} tracked`
5542
- }
5543
- ];
5544
- if (s.gsc) {
5545
- metrics.push({
5546
- label: "GSC clicks",
5547
- value: formatNumber(s.gsc.clicks),
5548
- delta: `${formatNumber(s.gsc.impressions)} imp \xB7 ${formatRatio(s.gsc.ctr)} CTR`
5549
- });
5550
- }
5551
- if (s.ga) {
5552
- metrics.push({
5553
- label: "GA sessions",
5554
- value: formatNumber(s.ga.sessions),
5555
- delta: `${formatNumber(s.ga.users)} users \xB7 ${formatDate(s.ga.periodStart)} \u2192 ${formatDate(s.ga.periodEnd)}`
5556
- });
5557
- }
5558
- const metricsHtml = `<div class="metric-grid">
5559
- ${metrics.map((m) => `<div class="metric">
5560
- <div class="label">${escapeHtml(m.label)}</div>
5561
- <div class="value">${m.value}</div>
5562
- <div class="delta">${m.delta}</div>
5563
- </div>`).join("")}
5564
- </div>`;
5565
- const findingsHtml = s.findings.length > 0 ? `<div class="findings">${s.findings.map((f) => `
5566
- <div class="finding tone-${f.tone}">
5567
- <strong>${escapeHtml(f.title)}</strong>
5568
- <span>${escapeHtml(f.detail)}</span>
5569
- </div>`).join("")}</div>` : "";
5570
- return section(
5571
- { id: "executive-summary", eyebrow: "Section 1", title: "Executive Summary" },
5572
- metricsHtml + findingsHtml
5573
- );
5574
- }
5575
- function renderProviderBars(rates) {
5576
- if (rates.length === 0) return "";
5577
- const max = Math.max(...rates.map((r) => r.citationRate), 100);
5578
- const width = 600;
5579
- const height = Math.max(rates.length * 32 + 24, 80);
5580
- const labelWidth = 80;
5581
- const padding = 8;
5582
- const barWidth = width - labelWidth - padding * 2;
5583
- const bars = rates.map((r, i) => {
5584
- const y = i * 32 + padding;
5585
- const barHeight = 22;
5586
- const w = max > 0 ? r.citationRate / max * barWidth : 0;
5587
- const color = COLORS.series[i % COLORS.series.length];
5588
- return `
5589
- <text x="${labelWidth - 8}" y="${y + 16}" fill="${COLORS.textMuted}" font-size="11" text-anchor="end">${escapeHtml(r.provider)}</text>
5590
- <rect x="${labelWidth}" y="${y}" width="${barWidth}" height="${barHeight}" fill="${COLORS.border}" opacity="0.4" rx="3" />
5591
- <rect x="${labelWidth}" y="${y}" width="${w}" height="${barHeight}" fill="${color}" rx="3" />
5592
- <text x="${labelWidth + w + 6}" y="${y + 16}" fill="${COLORS.text}" font-size="11">${r.citationRate}% (${r.citedCount}/${r.totalCount})</text>`;
5593
- }).join("");
5594
- return `<div class="chart-card">
5595
- <h3>Provider citation rate</h3>
5596
- <svg viewBox="0 0 ${width} ${height}" width="100%" preserveAspectRatio="xMinYMin meet" role="img" aria-label="Provider citation rate bar chart">
5597
- ${bars}
5598
- </svg>
5599
- </div>`;
5600
- }
5601
- function renderCitationMatrix(scorecard) {
5602
- if (scorecard.keywords.length === 0 || scorecard.providers.length === 0) {
5603
- return renderEmpty("Run a visibility sweep to populate the citation matrix.");
5604
- }
5605
- const headers = scorecard.providers.map((p) => `<th>${escapeHtml(p)}</th>`).join("");
5606
- const rows = scorecard.keywords.map((kw, ki) => {
5607
- const cells = scorecard.providers.map((_, pi) => {
5608
- const cell = scorecard.matrix[ki]?.[pi];
5609
- if (!cell) {
5610
- return '<td><span class="cell-pending">\u2014</span></td>';
5611
- }
5612
- if (cell.citationState === "cited") {
5613
- return '<td><span class="cell-cited">Cited</span></td>';
5614
- }
5615
- return '<td><span class="cell-not-cited">Not cited</span></td>';
5616
- }).join("");
5617
- return `<tr><td>${escapeHtml(kw)}</td>${cells}</tr>`;
5618
- }).join("");
5619
- return `<table class="report-table">
5620
- <thead><tr><th>Keyword</th>${headers}</tr></thead>
5621
- <tbody>${rows}</tbody>
5622
- </table>`;
5623
- }
5624
- function renderCitationScorecard(report) {
5625
- const body = `
5626
- ${renderProviderBars(report.citationScorecard.providerRates)}
5627
- ${renderCitationMatrix(report.citationScorecard)}
5628
- `;
5629
- return section(
5630
- { id: "citation-scorecard", eyebrow: "Section 2", title: "Citation Scorecard", intro: "Per-keyword \xD7 per-provider citation matrix from the latest visibility sweep." },
5631
- body
5632
- );
5633
- }
5634
- function renderCompetitorBars(landscape, canonical) {
5635
- const data = [
5636
- { label: canonical, count: landscape.projectCitationCount, isProject: true },
5637
- ...landscape.competitors.map((c) => ({ label: c.domain, count: c.citationCount, isProject: false }))
5638
- ];
5639
- if (data.length <= 1) return "";
5640
- const max = Math.max(...data.map((d) => d.count), 1);
5641
- const width = 600;
5642
- const height = data.length * 28 + 16;
5643
- const labelWidth = 160;
5644
- const bars = data.map((d, i) => {
5645
- const y = i * 28 + 8;
5646
- const barHeight = 18;
5647
- const w = d.count / max * (width - labelWidth - 60);
5648
- const color = d.isProject ? COLORS.accent : COLORS.series[(i + 1) % COLORS.series.length];
5649
- return `
5650
- <text x="${labelWidth - 8}" y="${y + 13}" fill="${COLORS.textMuted}" font-size="11" text-anchor="end">${escapeHtml(d.label)}</text>
5651
- <rect x="${labelWidth}" y="${y}" width="${w}" height="${barHeight}" fill="${color}" rx="3" />
5652
- <text x="${labelWidth + w + 6}" y="${y + 13}" fill="${COLORS.text}" font-size="11">${d.count}</text>`;
5653
- }).join("");
5654
- return `<div class="chart-card">
5655
- <h3>Citations per domain</h3>
5656
- <svg viewBox="0 0 ${width} ${height}" width="100%" preserveAspectRatio="xMinYMin meet" role="img" aria-label="Citations per domain bar chart">
5657
- ${bars}
5658
- </svg>
5659
- </div>`;
5660
- }
5661
- function renderCompetitorLandscape(report) {
5662
- const competitors2 = report.competitorLandscape.competitors;
5663
- if (competitors2.length === 0 && report.competitorLandscape.projectCitationCount === 0) {
5664
- return section(
5665
- { id: "competitor-landscape", eyebrow: "Section 3", title: "Competitor Landscape" },
5666
- renderEmpty("No competitor data yet. Add competitors and run a visibility sweep.")
5667
- );
5668
- }
5669
- const rows = competitors2.map((c) => {
5670
- const tone = pressureTone(c.pressureLabel);
5671
- const pagesDisclosure = c.theirCitedPages.length > 0 ? `<details class="cited-pages"><summary>${c.theirCitedPages.length} cited URL${c.theirCitedPages.length > 1 ? "s" : ""}</summary>
5672
- <ul>${c.theirCitedPages.map((p) => `<li><a href="${escapeHtml(p.url)}">${escapeHtml(p.url)}</a> <span class="cited-for">${escapeHtml(p.citedFor.join(", "))}</span></li>`).join("")}</ul>
5673
- </details>` : "";
5674
- return `<tr>
5675
- <td>${escapeHtml(c.domain)}</td>
5676
- <td><span class="badge tone-${tone}">${escapeHtml(c.pressureLabel)}</span></td>
5677
- <td class="numeric">${c.citationCount} / ${c.totalCount}</td>
5678
- <td class="numeric">${c.sharePct}%</td>
5679
- <td>${escapeHtml(c.citedKeywords.slice(0, 5).join(", "))}${c.citedKeywords.length > 5 ? "\u2026" : ""}${pagesDisclosure}</td>
5680
- </tr>`;
5681
- }).join("");
5682
- const table = competitors2.length > 0 ? `<table class="report-table">
5683
- <thead><tr><th>Domain</th><th>Pressure</th><th>Citations</th><th class="numeric">SOV</th><th>Cited keywords</th></tr></thead>
5684
- <tbody>${rows}</tbody>
5685
- </table>` : renderEmpty("No competitors configured.");
5686
- return section(
5687
- { id: "competitor-landscape", eyebrow: "Section 3", title: "Competitor Landscape", intro: "Where tracked competitors appear in AI answers compared to your domain." },
5688
- `${renderCompetitorBars(report.competitorLandscape, report.meta.project.canonicalDomain)}${table}`
5689
- );
5690
- }
5691
- function renderDonut(buckets) {
5692
- if (buckets.length === 0) return "";
5693
- const total = buckets.reduce((s, b) => s + b.count, 0);
5694
- if (total === 0) return "";
5695
- const cx = 110;
5696
- const cy = 110;
5697
- const r = 80;
5698
- const innerR = 48;
5699
- let cumulative = 0;
5700
- const slices = [];
5701
- const legend = [];
5702
- buckets.forEach((b, i) => {
5703
- const startAngle = cumulative / total * Math.PI * 2 - Math.PI / 2;
5704
- const endAngle = (cumulative + b.count) / total * Math.PI * 2 - Math.PI / 2;
5705
- cumulative += b.count;
5706
- const x1 = cx + Math.cos(startAngle) * r;
5707
- const y1 = cy + Math.sin(startAngle) * r;
5708
- const x2 = cx + Math.cos(endAngle) * r;
5709
- const y2 = cy + Math.sin(endAngle) * r;
5710
- const ix1 = cx + Math.cos(endAngle) * innerR;
5711
- const iy1 = cy + Math.sin(endAngle) * innerR;
5712
- const ix2 = cx + Math.cos(startAngle) * innerR;
5713
- const iy2 = cy + Math.sin(startAngle) * innerR;
5714
- const largeArc = endAngle - startAngle > Math.PI ? 1 : 0;
5715
- const color = COLORS.series[i % COLORS.series.length];
5716
- if (b.count > 0) {
5717
- slices.push(`<path d="M ${x1} ${y1} A ${r} ${r} 0 ${largeArc} 1 ${x2} ${y2} L ${ix1} ${iy1} A ${innerR} ${innerR} 0 ${largeArc} 0 ${ix2} ${iy2} Z" fill="${color}" />`);
5718
- legend.push(`<span><span class="legend-swatch" style="background:${color}"></span>${escapeHtml(b.label)} (${b.count})</span>`);
5719
- }
5720
- });
5721
- return `<div class="chart-card">
5722
- <h3>AI source categories</h3>
5723
- <div style="display:flex;align-items:center;gap:24px;flex-wrap:wrap;">
5724
- <svg viewBox="0 0 220 220" width="220" height="220" role="img" aria-label="AI source category donut chart">
5725
- ${slices.join("")}
5726
- </svg>
5727
- <div class="legend" style="flex-direction:column;align-items:flex-start;gap:6px;">${legend.join("")}</div>
5728
- </div>
5729
- </div>`;
5730
- }
5731
- function renderAiSourceOrigin(report) {
5732
- const origin = report.aiSourceOrigin;
5733
- if (origin.categories.length === 0 && origin.topDomains.length === 0) {
5734
- return section(
5735
- { id: "ai-source-origin", eyebrow: "Section 4", title: "AI Source Origin" },
5736
- renderEmpty("No source data yet. Run a visibility sweep first.")
5737
- );
5738
- }
5739
- const rows = origin.topDomains.map((d) => `
5740
- <tr>
5741
- <td>${escapeHtml(d.domain)}</td>
5742
- <td class="numeric">${d.count}</td>
5743
- <td>${d.isCompetitor ? '<span class="badge tone-negative">Competitor</span>' : '<span class="badge tone-neutral">External</span>'}</td>
5744
- </tr>`).join("");
5745
- const table = origin.topDomains.length > 0 ? `<table class="report-table">
5746
- <thead><tr><th>Domain</th><th>Citations</th><th>Tag</th></tr></thead>
5747
- <tbody>${rows}</tbody>
5748
- </table>` : "";
5749
- return section(
5750
- { id: "ai-source-origin", eyebrow: "Section 4", title: "AI Source Origin", intro: "Where AI answers pull from, aggregated across the latest sweep." },
5751
- `${renderDonut(origin.categories)}${table}`
5752
- );
5753
- }
5754
- function renderLineChart(points, color, title, height = 200) {
5755
- if (points.length === 0) return "";
5756
- const width = 600;
5757
- const padX = 32;
5758
- const padY = 24;
5759
- const usableW = width - padX * 2;
5760
- const usableH = height - padY * 2;
5761
- const max = Math.max(...points.map((p) => p.y), 1);
5762
- const stepX = points.length > 1 ? usableW / (points.length - 1) : 0;
5763
- const xy = points.map((p, i) => ({
5764
- x: padX + i * stepX,
5765
- y: padY + usableH - p.y / max * usableH,
5766
- raw: p
5767
- }));
5768
- const path10 = xy.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x.toFixed(1)} ${p.y.toFixed(1)}`).join(" ");
5769
- const dots = xy.map((p) => `<circle cx="${p.x.toFixed(1)}" cy="${p.y.toFixed(1)}" r="3" fill="${color}" />`).join("");
5770
- const xLabels = xy.map((p, i) => {
5771
- if (points.length > 8 && i % Math.ceil(points.length / 6) !== 0 && i !== points.length - 1) return "";
5772
- return `<text x="${p.x.toFixed(1)}" y="${(height - 4).toFixed(1)}" fill="${COLORS.textFaint}" font-size="9" text-anchor="middle">${escapeHtml(p.raw.label ?? p.raw.x)}</text>`;
5773
- }).join("");
5774
- return `<div class="chart-card">
5775
- <h3>${escapeHtml(title)}</h3>
5776
- <svg viewBox="0 0 ${width} ${height}" width="100%" preserveAspectRatio="xMinYMin meet" role="img" aria-label="${escapeHtml(title)} line chart">
5777
- <line x1="${padX}" y1="${padY + usableH}" x2="${padX + usableW}" y2="${padY + usableH}" stroke="${COLORS.border}" stroke-width="1" />
5778
- <text x="${padX - 6}" y="${(padY + 4).toFixed(1)}" fill="${COLORS.textFaint}" font-size="9" text-anchor="end">${formatNumber(max)}</text>
5779
- <text x="${padX - 6}" y="${(padY + usableH).toFixed(1)}" fill="${COLORS.textFaint}" font-size="9" text-anchor="end">0</text>
5780
- <path d="${path10}" stroke="${color}" stroke-width="2" fill="none" />
5781
- ${dots}
5782
- ${xLabels}
5783
- </svg>
5784
- </div>`;
5785
- }
5786
- function renderGsc(report) {
5787
- const gsc = report.gsc;
5788
- if (!gsc) {
5789
- return section(
5790
- { id: "gsc", eyebrow: "Section 5", title: "GSC Performance" },
5791
- renderEmpty("Connect Google Search Console to populate this section.")
5792
- );
5793
- }
5794
- const rows = gsc.topQueries.map((q) => `
5795
- <tr>
5796
- <td>${escapeHtml(q.query)}</td>
5797
- <td class="numeric">${formatNumber(q.clicks)}</td>
5798
- <td class="numeric">${formatNumber(q.impressions)}</td>
5799
- <td class="numeric">${formatRatio(q.ctr)}</td>
5800
- <td class="numeric">${q.avgPosition.toFixed(1)}</td>
5801
- <td><span class="badge tone-neutral">${escapeHtml(q.category)}</span></td>
5802
- </tr>`).join("");
5803
- const breakdownRows = gsc.categoryBreakdown.map((c) => `
5804
- <tr>
5805
- <td>${escapeHtml(c.category)}</td>
5806
- <td class="numeric">${formatNumber(c.clicks)}</td>
5807
- <td class="numeric">${formatNumber(c.impressions)}</td>
5808
- <td class="numeric">${c.sharePct}%</td>
5809
- </tr>`).join("");
5810
- const trendChart = renderLineChart(
5811
- gsc.trend.map((t) => ({ x: t.date, y: t.clicks, label: t.date.slice(5) })),
5812
- COLORS.accent,
5813
- "Clicks over time"
5814
- );
5815
- const crossoverBlocks = [];
5816
- if (gsc.trackedButNoGsc.length > 0) {
5817
- crossoverBlocks.push(`<div class="chart-card"><h3>AEO keywords without search demand</h3>
5818
- <p class="section-intro">Tracked AEO keywords with no GSC impressions in this window \u2014 review whether they represent real search demand.</p>
5819
- <ul>${gsc.trackedButNoGsc.map((k) => `<li>${escapeHtml(k)}</li>`).join("")}</ul>
5820
- </div>`);
5821
- }
5822
- if (gsc.gscButNotTracked.length > 0) {
5823
- crossoverBlocks.push(`<div class="chart-card"><h3>Search queries you should track</h3>
5824
- <p class="section-intro">GSC top queries (by impressions) that aren't tracked in your AEO project \u2014 candidates to add as keywords.</p>
5825
- <ul>${gsc.gscButNotTracked.map((q) => `<li>${escapeHtml(q)}</li>`).join("")}</ul>
5826
- </div>`);
5827
- }
5828
- return section(
5829
- { id: "gsc", eyebrow: "Section 5", title: "GSC Performance", intro: "Top queries, category breakdown, and traffic trend from Google Search Console." },
5830
- `<div class="metric-grid">
5831
- <div class="metric"><div class="label">Total clicks</div><div class="value">${formatNumber(gsc.totalClicks)}</div></div>
5832
- <div class="metric"><div class="label">Total impressions</div><div class="value">${formatNumber(gsc.totalImpressions)}</div></div>
5833
- <div class="metric"><div class="label">Avg CTR</div><div class="value">${formatRatio(gsc.ctr)}</div></div>
5834
- <div class="metric"><div class="label">Avg position</div><div class="value">${gsc.avgPosition.toFixed(1)}</div></div>
5835
- </div>
5836
- ${trendChart}
5837
- <div class="chart-card"><h3>Top queries</h3>
5838
- <table class="report-table">
5839
- <thead><tr><th>Query</th><th class="numeric">Clicks</th><th class="numeric">Imp.</th><th class="numeric">CTR</th><th class="numeric">Pos.</th><th>Category</th></tr></thead>
5840
- <tbody>${rows}</tbody>
5841
- </table>
5842
- </div>
5843
- <div class="chart-card"><h3>Category breakdown</h3>
5844
- <table class="report-table">
5845
- <thead><tr><th>Category</th><th class="numeric">Clicks</th><th class="numeric">Imp.</th><th class="numeric">Share</th></tr></thead>
5846
- <tbody>${breakdownRows}</tbody>
5847
- </table>
5848
- </div>
5849
- ${crossoverBlocks.join("\n")}`
5850
- );
5851
- }
5852
- function renderGa(report) {
5853
- const ga = report.ga;
5854
- if (!ga) {
5855
- return section(
5856
- { id: "ga", eyebrow: "Section 6", title: "GA4 Traffic" },
5857
- renderEmpty("Connect Google Analytics 4 to populate this section.")
5858
- );
5859
- }
5860
- const pageRows = ga.topLandingPages.map((p) => `
5861
- <tr>
5862
- <td class="page-cell">${formatLandingPageHtml(p.page)}</td>
5863
- <td class="numeric">${formatNumber(p.sessions)}</td>
5864
- <td class="numeric">${formatNumber(p.users)}</td>
5865
- <td class="numeric">${formatNumber(p.organicSessions)}</td>
5866
- </tr>`).join("");
5867
- const channelRows = ga.channelBreakdown.map((c) => `
5868
- <tr>
5869
- <td>${escapeHtml(c.channel)}</td>
5870
- <td class="numeric">${formatNumber(c.sessions)}</td>
5871
- <td class="numeric">${c.sharePct}%</td>
5872
- </tr>`).join("");
5873
- return section(
5874
- { id: "ga", eyebrow: "Section 6", title: "GA4 Traffic", intro: `Sessions and users for ${formatDate(ga.periodStart)} \u2192 ${formatDate(ga.periodEnd)}.` },
5875
- `<div class="metric-grid">
5876
- <div class="metric"><div class="label">Total sessions</div><div class="value">${formatNumber(ga.totalSessions)}</div></div>
5877
- <div class="metric"><div class="label">Total users</div><div class="value">${formatNumber(ga.totalUsers)}</div></div>
5878
- <div class="metric"><div class="label">Organic sessions</div><div class="value">${formatNumber(ga.totalOrganicSessions)}</div></div>
5879
- </div>
5880
- <div class="chart-card"><h3>Top landing pages</h3>
5881
- <table class="report-table">
5882
- <thead><tr><th>Page</th><th class="numeric">Sessions</th><th class="numeric">Users</th><th class="numeric">Organic</th></tr></thead>
5883
- <tbody>${pageRows}</tbody>
5884
- </table>
5885
- </div>
5886
- <div class="chart-card"><h3>Channel breakdown</h3>
5887
- <table class="report-table">
5888
- <thead><tr><th>Channel</th><th class="numeric">Sessions</th><th class="numeric">Share</th></tr></thead>
5889
- <tbody>${channelRows}</tbody>
5890
- </table>
5891
- </div>`
5892
- );
5893
- }
5894
- function renderSocial(report) {
5895
- const social = report.socialReferrals;
5896
- if (!social) {
5897
- return section(
5898
- { id: "social-referrals", eyebrow: "Section 7", title: "Social Referrals" },
5899
- renderEmpty("No social referral data yet.")
5900
- );
5901
- }
5902
- const channelRows = social.channels.map((c) => `
5903
- <tr>
5904
- <td>${escapeHtml(c.channelGroup)}</td>
5905
- <td class="numeric">${formatNumber(c.sessions)}</td>
5906
- <td class="numeric">${c.sharePct}%</td>
5907
- </tr>`).join("");
5908
- const campaignRows = social.topCampaigns.map((c) => `
5909
- <tr>
5910
- <td>${escapeHtml(c.source)}</td>
5911
- <td>${escapeHtml(c.medium)}</td>
5912
- <td class="numeric">${formatNumber(c.sessions)}</td>
5913
- </tr>`).join("");
5914
- return section(
5915
- { id: "social-referrals", eyebrow: "Section 7", title: "Social Referrals", intro: "Paid vs organic split with top campaigns." },
5916
- `<div class="metric-grid">
5917
- <div class="metric"><div class="label">Total sessions</div><div class="value">${formatNumber(social.totalSessions)}</div></div>
5918
- <div class="metric"><div class="label">Organic social</div><div class="value">${formatNumber(social.organicSessions)}</div></div>
5919
- <div class="metric"><div class="label">Paid social</div><div class="value">${formatNumber(social.paidSessions)}</div></div>
5920
- </div>
5921
- <div class="chart-card"><h3>Channel groups</h3>
5922
- <table class="report-table">
5923
- <thead><tr><th>Channel</th><th class="numeric">Sessions</th><th class="numeric">Share</th></tr></thead>
5924
- <tbody>${channelRows}</tbody>
5925
- </table>
5926
- </div>
5927
- <div class="chart-card"><h3>Top campaigns</h3>
5928
- <table class="report-table">
5929
- <thead><tr><th>Source</th><th>Medium</th><th class="numeric">Sessions</th></tr></thead>
5930
- <tbody>${campaignRows}</tbody>
5931
- </table>
5932
- </div>`
5933
- );
5934
- }
5935
- function renderAiReferrals(report) {
5936
- const ai = report.aiReferrals;
5937
- if (!ai) {
5938
- return section(
5939
- { id: "ai-referrals", eyebrow: "Section 8", title: "AI Referral Traffic" },
5940
- renderEmpty("No AI referral traffic detected yet.")
5941
- );
5942
- }
5943
- const sourceRows = ai.bySource.map((s) => `
5944
- <tr>
5945
- <td>${escapeHtml(s.source)}</td>
5946
- <td class="numeric">${formatNumber(s.sessions)}</td>
5947
- <td class="numeric">${formatNumber(s.users)}</td>
5948
- <td class="numeric">${s.sharePct}%</td>
5949
- </tr>`).join("");
5950
- const pageRows = ai.topLandingPages.map((p) => `
5951
- <tr>
5952
- <td class="page-cell">${formatLandingPageHtml(p.page)}</td>
5953
- <td class="numeric">${formatNumber(p.sessions)}</td>
5954
- <td class="numeric">${formatNumber(p.users)}</td>
5955
- </tr>`).join("");
5956
- const trendChart = renderLineChart(
5957
- ai.trend.map((t) => ({ x: t.date, y: t.sessions, label: t.date.slice(5) })),
5958
- COLORS.series[2],
5959
- "AI referral sessions over time"
5960
- );
5961
- return section(
5962
- { id: "ai-referrals", eyebrow: "Section 8", title: "AI Referral Traffic", intro: "Sessions sent from AI answer engines." },
5963
- `<div class="metric-grid">
5964
- <div class="metric"><div class="label">Total sessions</div><div class="value">${formatNumber(ai.totalSessions)}</div></div>
5965
- <div class="metric"><div class="label">Total users</div><div class="value">${formatNumber(ai.totalUsers)}</div></div>
5966
- </div>
5967
- ${trendChart}
5968
- <div class="chart-card"><h3>Sessions by source</h3>
5969
- <table class="report-table">
5970
- <thead><tr><th>Source</th><th class="numeric">Sessions</th><th class="numeric">Users</th><th class="numeric">Share</th></tr></thead>
5971
- <tbody>${sourceRows}</tbody>
5972
- </table>
5973
- </div>
5974
- <div class="chart-card"><h3>Top AI landing pages</h3>
5975
- <table class="report-table">
5976
- <thead><tr><th>Page</th><th class="numeric">Sessions</th><th class="numeric">Users</th></tr></thead>
5977
- <tbody>${pageRows}</tbody>
5978
- </table>
5979
- </div>`
5980
- );
5981
- }
5982
- function renderIndexingHealth(report) {
5983
- const ih = report.indexingHealth;
5984
- if (!ih) {
5985
- return section(
5986
- { id: "indexing-health", eyebrow: "Section 9", title: "Indexing Health" },
5987
- renderEmpty("Connect Google Search Console or Bing Webmaster Tools and run a sitemap inspection.")
5988
- );
5989
- }
5990
- const segments = [
5991
- { label: "Indexed", count: ih.indexed, color: COLORS.positive },
5992
- { label: "Not indexed", count: ih.notIndexed, color: COLORS.caution },
5993
- { label: "Deindexed", count: ih.deindexed, color: COLORS.negative },
5994
- { label: "Unknown", count: ih.unknown, color: COLORS.neutral }
5995
- ].filter((s) => s.count > 0);
5996
- const total = segments.reduce((s, x) => s + x.count, 0) || 1;
5997
- const width = 600;
5998
- const height = 28;
5999
- let acc = 0;
6000
- const bars = segments.map((s) => {
6001
- const w = s.count / total * width;
6002
- const x = acc;
6003
- acc += w;
6004
- return `<rect x="${x}" y="0" width="${w}" height="${height}" fill="${s.color}" />`;
6005
- }).join("");
6006
- const legend = segments.map((s) => `<span><span class="legend-swatch" style="background:${s.color}"></span>${escapeHtml(s.label)}: ${s.count}</span>`).join("");
6007
- return section(
6008
- { id: "indexing-health", eyebrow: "Section 9", title: "Indexing Health", intro: `Source: ${ih.provider === "google" ? "Google Search Console" : "Bing Webmaster Tools"}.` },
6009
- `<div class="metric-grid">
6010
- <div class="metric"><div class="label">Indexed</div><div class="value tone-positive">${formatNumber(ih.indexed)}</div></div>
6011
- <div class="metric"><div class="label">Total inspected</div><div class="value">${formatNumber(ih.total)}</div></div>
6012
- <div class="metric"><div class="label">Indexed share</div><div class="value">${ih.indexedPct}%</div></div>
6013
- </div>
6014
- <div class="chart-card">
6015
- <h3>Coverage breakdown</h3>
6016
- <svg viewBox="0 0 ${width} ${height}" width="100%" preserveAspectRatio="xMinYMin meet" role="img" aria-label="Coverage stacked bar">${bars}</svg>
6017
- <div class="legend">${legend}</div>
6018
- </div>`
6019
- );
6020
- }
6021
- function renderCitationsTrend(report) {
6022
- const trend = report.citationsTrend;
6023
- if (trend.length === 0) {
6024
- return section(
6025
- { id: "citations-trend", eyebrow: "Section 10", title: "Citations Over Time" },
6026
- renderEmpty("Run multiple visibility sweeps to see a trend.")
6027
- );
6028
- }
6029
- if (isTrendBaseline(trend)) {
6030
- return section(
6031
- { id: "citations-trend", eyebrow: "Section 10", title: "Citations Over Time" },
6032
- renderEmpty(`Establishing baseline (${trend.length} of ${MIN_TREND_POINTS} runs collected). Trend will appear once more sweeps are recorded.`)
6033
- );
6034
- }
6035
- const chart = renderLineChart(
6036
- trend.map((t) => ({ x: t.date, y: t.citationRate, label: formatDate(t.date) })),
6037
- COLORS.positive,
6038
- "Overall citation rate",
6039
- 220
6040
- );
6041
- const rows = trend.map((t) => `
6042
- <tr>
6043
- <td>${formatDate(t.date)}</td>
6044
- <td class="numeric">${t.citationRate}%</td>
6045
- <td>${t.providerRates.map((r) => `${escapeHtml(r.provider)}: ${r.citationRate}%`).join(" \xB7 ")}</td>
6046
- </tr>`).join("");
6047
- return section(
6048
- { id: "citations-trend", eyebrow: "Section 10", title: "Citations Over Time", intro: "Per-run citation rate across the project history." },
6049
- `${chart}
6050
- <div class="chart-card"><h3>Run-by-run breakdown</h3>
6051
- <table class="report-table">
6052
- <thead><tr><th>Run</th><th class="numeric">Overall rate</th><th>Per-provider rates</th></tr></thead>
6053
- <tbody>${rows}</tbody>
6054
- </table>
6055
- </div>`
6056
- );
6057
- }
6058
- function renderInsights(report) {
6059
- const list = report.insights;
6060
- if (list.length === 0) {
6061
- return section(
6062
- { id: "insights", eyebrow: "Section 11", title: "Insights & Alerts" },
6063
- renderEmpty("No insights yet \u2014 run a visibility sweep to generate alerts.")
6064
- );
6065
- }
6066
- const haveDeduped = list.every((i) => typeof i.instanceCount === "number");
6067
- const rows = (haveDeduped ? list.map((i) => ({ rep: i, count: i.instanceCount })) : groupInsights(list).map((g) => ({ rep: g.representative, count: g.count }))).map(({ rep: i, count }) => {
6068
- const tone = severityTone(i.severity);
6069
- const countChip = count > 1 ? ` <span class="badge tone-neutral">\xD7 ${count}</span>` : "";
6070
- return `<tr>
6071
- <td><span class="badge tone-${tone}">${escapeHtml(i.severity)}</span></td>
6072
- <td>${escapeHtml(i.title)}${countChip}</td>
6073
- <td>${escapeHtml(i.keyword)}</td>
6074
- <td>${escapeHtml(i.provider)}</td>
6075
- <td>${i.recommendation ? escapeHtml(i.recommendation) : '<span class="cell-pending">\u2014</span>'}</td>
6076
- </tr>`;
6077
- }).join("");
6078
- return section(
6079
- { id: "insights", eyebrow: "Section 11", title: "Insights & Alerts", intro: "Priority-ordered findings from the most recent runs." },
6080
- `<table class="report-table">
6081
- <thead><tr><th>Severity</th><th>Title</th><th>Keyword</th><th>Provider</th><th>Recommendation</th></tr></thead>
6082
- <tbody>${rows}</tbody>
6083
- </table>`
6084
- );
6085
- }
6086
- function renderOpportunities(report) {
6087
- const opps = report.contentOpportunities;
6088
- if (opps.length === 0) return "";
6089
- const rows = opps.slice(0, 10).map((o) => {
6090
- const ourPage = o.ourBestPage ? `<a href="${escapeHtml(o.ourBestPage.url)}">${escapeHtml(o.ourBestPage.url)}</a>` : '<span class="cell-not-cited">\u2014</span>';
6091
- const winning = o.winningCompetitor ? `<a href="${escapeHtml(o.winningCompetitor.url)}">${escapeHtml(o.winningCompetitor.domain)}</a>` : '<span class="cell-not-cited">\u2014</span>';
6092
- return `<tr>
6093
- <td>${escapeHtml(o.query)}</td>
6094
- <td><span class="badge tone-neutral">${escapeHtml(o.action)}</span></td>
6095
- <td class="numeric">${Math.round(o.score)}</td>
6096
- <td>${ourPage}</td>
6097
- <td>${winning}</td>
6098
- <td><span class="badge tone-neutral">${escapeHtml(o.demandSource)}</span></td>
6099
- <td><span class="badge tone-neutral">${escapeHtml(o.actionConfidence)}</span></td>
6100
- </tr>`;
6101
- }).join("");
6102
- return section(
6103
- {
6104
- id: "content-opportunities",
6105
- eyebrow: "Section 12",
6106
- title: "Content Opportunities",
6107
- intro: "Ranked, action-typed targets from the content recommendation engine. Top 10 shown."
6108
- },
6109
- `<table class="report-table">
6110
- <thead><tr><th>Query</th><th>Action</th><th class="numeric">Score</th><th>Our page</th><th>Winning competitor</th><th>Demand</th><th>Confidence</th></tr></thead>
6111
- <tbody>${rows}</tbody>
6112
- </table>`
6113
- );
6114
- }
6115
- function renderRecommendedNextSteps(report) {
6116
- const steps = report.recommendedNextSteps;
6117
- if (steps.length === 0) {
6118
- return section(
6119
- { id: "recommended-next-steps", eyebrow: "Section 13", title: "Recommended Next Steps" },
6120
- renderEmpty("No outstanding actions.")
6121
- );
6122
- }
6123
- const items = steps.map((s) => `
6124
- <div class="step">
6125
- <span class="horizon">${escapeHtml(s.horizon)}</span>
6126
- <span class="title">${escapeHtml(s.title)}</span>
6127
- <span class="rationale">${escapeHtml(s.rationale)}</span>
6128
- </div>`).join("");
6129
- return section(
6130
- { id: "recommended-next-steps", eyebrow: "Section 13", title: "Recommended Next Steps" },
6131
- `<div class="steps">${items}</div>`
6132
- );
6133
- }
6134
- function escapeJsonForScript(json) {
6135
- return json.replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
6136
- }
6137
- function renderReportHtml(report, opts = {}) {
6138
- const title = opts.title ?? `Canonry report \u2014 ${report.meta.project.displayName}`;
6139
- const sections = [
6140
- renderExecutiveSummary(report),
6141
- renderCitationScorecard(report),
6142
- renderCompetitorLandscape(report),
6143
- renderAiSourceOrigin(report),
6144
- renderGsc(report),
6145
- renderGa(report),
6146
- renderSocial(report),
6147
- renderAiReferrals(report),
6148
- renderIndexingHealth(report),
6149
- renderCitationsTrend(report),
6150
- renderInsights(report),
6151
- renderOpportunities(report),
6152
- renderRecommendedNextSteps(report)
6153
- ].join("\n");
6154
- const json = escapeJsonForScript(JSON.stringify(report));
6155
- return `<!DOCTYPE html>
6156
- <html lang="en">
6157
- <head>
6158
- <meta charset="utf-8" />
6159
- <meta name="viewport" content="width=device-width, initial-scale=1" />
6160
- <title>${escapeHtml(title)}</title>
6161
- <style>${STYLE}</style>
6162
- </head>
6163
- <body>
6164
- <div class="container">
6165
- <header class="header">
6166
- <div class="eyebrow">AEO Report</div>
6167
- <h1>${escapeHtml(report.meta.project.displayName)}</h1>
6168
- <div class="subtitle">${escapeHtml(report.meta.project.canonicalDomain)} \xB7 ${escapeHtml(report.meta.project.country)} / ${escapeHtml(report.meta.project.language.toUpperCase())} \xB7 Generated ${formatDate(report.meta.generatedAt)}</div>
6169
- </header>
6170
- ${sections}
6171
- <footer class="footer">Generated by canonry \xB7 ${escapeHtml(report.meta.generatedAt)}</footer>
6172
- </div>
6173
- <script type="application/json" id="canonry-report-data">${json}</script>
6174
- </body>
6175
- </html>`;
6176
- }
6177
-
6178
- // src/commands/report.ts
6179
5182
  function defaultOutputPath(project) {
6180
5183
  const date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
6181
5184
  return path3.resolve(process.cwd(), `canonry-report-${project}-${date}.html`);