@ainyc/canonry 3.3.2 → 3.3.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/agent-workspace/skills/aero/references/reporting.md +20 -0
- package/assets/agent-workspace/skills/canonry-setup/SKILL.md +1 -1
- package/assets/agent-workspace/skills/canonry-setup/references/canonry-cli.md +18 -0
- package/dist/{chunk-ALMP3NBQ.js → chunk-24C7RMIS.js} +17 -2
- package/dist/{chunk-HQ47AA6H.js → chunk-P6D3O5JB.js} +898 -248
- package/dist/cli.js +1014 -94
- package/dist/index.js +2 -2
- package/dist/mcp.js +1 -1
- package/package.json +4 -4
package/dist/cli.js
CHANGED
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
setGoogleAuthConfig,
|
|
18
18
|
showFirstRunNotice,
|
|
19
19
|
trackEvent
|
|
20
|
-
} from "./chunk-
|
|
20
|
+
} from "./chunk-P6D3O5JB.js";
|
|
21
21
|
import {
|
|
22
22
|
CcReleaseSyncStatuses,
|
|
23
23
|
CheckScopes,
|
|
@@ -48,7 +48,7 @@ import {
|
|
|
48
48
|
saveConfigPatch,
|
|
49
49
|
skillsClientSchema,
|
|
50
50
|
usageError
|
|
51
|
-
} from "./chunk-
|
|
51
|
+
} from "./chunk-24C7RMIS.js";
|
|
52
52
|
import {
|
|
53
53
|
apiKeys,
|
|
54
54
|
competitors,
|
|
@@ -71,9 +71,9 @@ import { parseArgs } from "util";
|
|
|
71
71
|
function commandId(spec) {
|
|
72
72
|
return spec.path.join(".");
|
|
73
73
|
}
|
|
74
|
-
function matchesPath(args,
|
|
75
|
-
if (args.length <
|
|
76
|
-
return
|
|
74
|
+
function matchesPath(args, path10) {
|
|
75
|
+
if (args.length < path10.length) return false;
|
|
76
|
+
return path10.every((segment, index) => args[index] === segment);
|
|
77
77
|
}
|
|
78
78
|
function withFormatOption(options) {
|
|
79
79
|
if (!options) {
|
|
@@ -2015,9 +2015,9 @@ async function gaConnect(project, opts) {
|
|
|
2015
2015
|
propertyId: opts.propertyId
|
|
2016
2016
|
};
|
|
2017
2017
|
if (opts.keyFile) {
|
|
2018
|
-
const
|
|
2018
|
+
const fs11 = await import("fs");
|
|
2019
2019
|
try {
|
|
2020
|
-
const content =
|
|
2020
|
+
const content = fs11.readFileSync(opts.keyFile, "utf-8");
|
|
2021
2021
|
JSON.parse(content);
|
|
2022
2022
|
body.keyJson = content;
|
|
2023
2023
|
} catch (e) {
|
|
@@ -5174,6 +5174,925 @@ var PROJECT_CLI_COMMANDS = [
|
|
|
5174
5174
|
}
|
|
5175
5175
|
];
|
|
5176
5176
|
|
|
5177
|
+
// src/commands/report.ts
|
|
5178
|
+
import fs4 from "fs";
|
|
5179
|
+
import path3 from "path";
|
|
5180
|
+
|
|
5181
|
+
// src/report-renderer.ts
|
|
5182
|
+
var COLORS = {
|
|
5183
|
+
bg: "#09090b",
|
|
5184
|
+
surface: "#18181b4d",
|
|
5185
|
+
border: "#27272a99",
|
|
5186
|
+
text: "#fafafa",
|
|
5187
|
+
textMuted: "#a1a1aa",
|
|
5188
|
+
textFaint: "#71717a",
|
|
5189
|
+
positive: "#10b981",
|
|
5190
|
+
caution: "#f59e0b",
|
|
5191
|
+
negative: "#f43f5e",
|
|
5192
|
+
neutral: "#71717a",
|
|
5193
|
+
accent: "#3b82f6",
|
|
5194
|
+
series: ["#10b981", "#3b82f6", "#ec4899", "#eab308", "#a855f7", "#f97316", "#06b6d4", "#ef4444"]
|
|
5195
|
+
};
|
|
5196
|
+
function escapeHtml(value) {
|
|
5197
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
5198
|
+
}
|
|
5199
|
+
function formatRatio(value) {
|
|
5200
|
+
if (!Number.isFinite(value) || value === 0) return "0%";
|
|
5201
|
+
return `${(value * 100).toFixed(1)}%`;
|
|
5202
|
+
}
|
|
5203
|
+
function formatNumber(value) {
|
|
5204
|
+
if (!Number.isFinite(value)) return "\u2014";
|
|
5205
|
+
if (Math.abs(value) >= 1e6) return `${(value / 1e6).toFixed(1)}M`;
|
|
5206
|
+
if (Math.abs(value) >= 1e3) return `${(value / 1e3).toFixed(1)}K`;
|
|
5207
|
+
return value.toLocaleString("en-US");
|
|
5208
|
+
}
|
|
5209
|
+
function formatDate(iso) {
|
|
5210
|
+
if (!iso) return "\u2014";
|
|
5211
|
+
try {
|
|
5212
|
+
const d = new Date(iso);
|
|
5213
|
+
return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
|
5214
|
+
} catch {
|
|
5215
|
+
return iso;
|
|
5216
|
+
}
|
|
5217
|
+
}
|
|
5218
|
+
function pressureTone(label) {
|
|
5219
|
+
if (label === "High") return "negative";
|
|
5220
|
+
if (label === "Moderate") return "caution";
|
|
5221
|
+
if (label === "Low") return "positive";
|
|
5222
|
+
return "neutral";
|
|
5223
|
+
}
|
|
5224
|
+
function severityTone(severity) {
|
|
5225
|
+
switch (severity) {
|
|
5226
|
+
case "critical":
|
|
5227
|
+
return "negative";
|
|
5228
|
+
case "high":
|
|
5229
|
+
return "negative";
|
|
5230
|
+
case "medium":
|
|
5231
|
+
return "caution";
|
|
5232
|
+
case "low":
|
|
5233
|
+
return "neutral";
|
|
5234
|
+
}
|
|
5235
|
+
}
|
|
5236
|
+
var STYLE = `
|
|
5237
|
+
:root {
|
|
5238
|
+
color-scheme: dark;
|
|
5239
|
+
}
|
|
5240
|
+
* { box-sizing: border-box; }
|
|
5241
|
+
html, body { margin: 0; padding: 0; }
|
|
5242
|
+
body {
|
|
5243
|
+
background: ${COLORS.bg};
|
|
5244
|
+
color: ${COLORS.text};
|
|
5245
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
|
5246
|
+
font-size: 14px;
|
|
5247
|
+
line-height: 1.5;
|
|
5248
|
+
-webkit-font-smoothing: antialiased;
|
|
5249
|
+
}
|
|
5250
|
+
.container {
|
|
5251
|
+
max-width: 1100px;
|
|
5252
|
+
margin: 0 auto;
|
|
5253
|
+
padding: 48px 24px 96px;
|
|
5254
|
+
}
|
|
5255
|
+
.header {
|
|
5256
|
+
border-bottom: 1px solid ${COLORS.border};
|
|
5257
|
+
padding-bottom: 32px;
|
|
5258
|
+
margin-bottom: 48px;
|
|
5259
|
+
}
|
|
5260
|
+
.header h1 {
|
|
5261
|
+
font-size: 32px;
|
|
5262
|
+
font-weight: 700;
|
|
5263
|
+
margin: 0 0 8px;
|
|
5264
|
+
letter-spacing: -0.02em;
|
|
5265
|
+
}
|
|
5266
|
+
.header .subtitle {
|
|
5267
|
+
color: ${COLORS.textMuted};
|
|
5268
|
+
font-size: 14px;
|
|
5269
|
+
}
|
|
5270
|
+
.eyebrow {
|
|
5271
|
+
text-transform: uppercase;
|
|
5272
|
+
letter-spacing: 0.08em;
|
|
5273
|
+
font-size: 10px;
|
|
5274
|
+
color: ${COLORS.textFaint};
|
|
5275
|
+
font-weight: 600;
|
|
5276
|
+
margin-bottom: 8px;
|
|
5277
|
+
}
|
|
5278
|
+
section.report-section {
|
|
5279
|
+
margin: 64px 0;
|
|
5280
|
+
}
|
|
5281
|
+
section.report-section h2 {
|
|
5282
|
+
font-size: 22px;
|
|
5283
|
+
font-weight: 700;
|
|
5284
|
+
margin: 0 0 24px;
|
|
5285
|
+
letter-spacing: -0.01em;
|
|
5286
|
+
}
|
|
5287
|
+
section.report-section .section-intro {
|
|
5288
|
+
color: ${COLORS.textMuted};
|
|
5289
|
+
margin-bottom: 24px;
|
|
5290
|
+
}
|
|
5291
|
+
.metric-grid {
|
|
5292
|
+
display: grid;
|
|
5293
|
+
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
|
5294
|
+
gap: 16px;
|
|
5295
|
+
}
|
|
5296
|
+
.metric {
|
|
5297
|
+
background: ${COLORS.surface};
|
|
5298
|
+
border: 1px solid ${COLORS.border};
|
|
5299
|
+
border-radius: 8px;
|
|
5300
|
+
padding: 16px 20px;
|
|
5301
|
+
}
|
|
5302
|
+
.metric .label {
|
|
5303
|
+
text-transform: uppercase;
|
|
5304
|
+
letter-spacing: 0.08em;
|
|
5305
|
+
font-size: 10px;
|
|
5306
|
+
color: ${COLORS.textFaint};
|
|
5307
|
+
font-weight: 600;
|
|
5308
|
+
margin-bottom: 8px;
|
|
5309
|
+
}
|
|
5310
|
+
.metric .value {
|
|
5311
|
+
font-size: 28px;
|
|
5312
|
+
font-weight: 700;
|
|
5313
|
+
letter-spacing: -0.02em;
|
|
5314
|
+
}
|
|
5315
|
+
.metric .delta {
|
|
5316
|
+
font-size: 12px;
|
|
5317
|
+
color: ${COLORS.textMuted};
|
|
5318
|
+
margin-top: 4px;
|
|
5319
|
+
}
|
|
5320
|
+
.findings {
|
|
5321
|
+
margin-top: 24px;
|
|
5322
|
+
display: grid;
|
|
5323
|
+
gap: 12px;
|
|
5324
|
+
}
|
|
5325
|
+
.finding {
|
|
5326
|
+
background: ${COLORS.surface};
|
|
5327
|
+
border: 1px solid ${COLORS.border};
|
|
5328
|
+
border-left-width: 3px;
|
|
5329
|
+
border-radius: 6px;
|
|
5330
|
+
padding: 12px 16px;
|
|
5331
|
+
}
|
|
5332
|
+
.finding.tone-positive { border-left-color: ${COLORS.positive}; }
|
|
5333
|
+
.finding.tone-caution { border-left-color: ${COLORS.caution}; }
|
|
5334
|
+
.finding.tone-negative { border-left-color: ${COLORS.negative}; }
|
|
5335
|
+
.finding.tone-neutral { border-left-color: ${COLORS.neutral}; }
|
|
5336
|
+
.finding strong { display: block; margin-bottom: 4px; }
|
|
5337
|
+
.finding span { color: ${COLORS.textMuted}; font-size: 13px; }
|
|
5338
|
+
table.report-table {
|
|
5339
|
+
width: 100%;
|
|
5340
|
+
border-collapse: collapse;
|
|
5341
|
+
font-size: 13px;
|
|
5342
|
+
}
|
|
5343
|
+
table.report-table th, table.report-table td {
|
|
5344
|
+
text-align: left;
|
|
5345
|
+
padding: 10px 12px;
|
|
5346
|
+
border-bottom: 1px solid ${COLORS.border};
|
|
5347
|
+
}
|
|
5348
|
+
table.report-table th {
|
|
5349
|
+
font-weight: 600;
|
|
5350
|
+
color: ${COLORS.textMuted};
|
|
5351
|
+
text-transform: uppercase;
|
|
5352
|
+
letter-spacing: 0.06em;
|
|
5353
|
+
font-size: 10px;
|
|
5354
|
+
}
|
|
5355
|
+
table.report-table td.numeric { text-align: right; font-variant-numeric: tabular-nums; }
|
|
5356
|
+
table.report-table td .badge {
|
|
5357
|
+
display: inline-block;
|
|
5358
|
+
padding: 2px 8px;
|
|
5359
|
+
border-radius: 999px;
|
|
5360
|
+
font-size: 11px;
|
|
5361
|
+
font-weight: 600;
|
|
5362
|
+
border: 1px solid;
|
|
5363
|
+
}
|
|
5364
|
+
.cell-cited { color: ${COLORS.positive}; font-weight: 600; }
|
|
5365
|
+
.cell-not-cited { color: ${COLORS.textFaint}; }
|
|
5366
|
+
.cell-pending { color: ${COLORS.textFaint}; font-style: italic; }
|
|
5367
|
+
.tone-positive { color: ${COLORS.positive}; }
|
|
5368
|
+
.tone-caution { color: ${COLORS.caution}; }
|
|
5369
|
+
.tone-negative { color: ${COLORS.negative}; }
|
|
5370
|
+
.tone-neutral { color: ${COLORS.neutral}; }
|
|
5371
|
+
.badge.tone-positive { color: ${COLORS.positive}; border-color: ${COLORS.positive}40; background: ${COLORS.positive}14; }
|
|
5372
|
+
.badge.tone-caution { color: ${COLORS.caution}; border-color: ${COLORS.caution}40; background: ${COLORS.caution}14; }
|
|
5373
|
+
.badge.tone-negative { color: ${COLORS.negative}; border-color: ${COLORS.negative}40; background: ${COLORS.negative}14; }
|
|
5374
|
+
.badge.tone-neutral { color: ${COLORS.textMuted}; border-color: ${COLORS.border}; background: transparent; }
|
|
5375
|
+
.chart-card {
|
|
5376
|
+
background: ${COLORS.surface};
|
|
5377
|
+
border: 1px solid ${COLORS.border};
|
|
5378
|
+
border-radius: 8px;
|
|
5379
|
+
padding: 20px;
|
|
5380
|
+
margin-bottom: 16px;
|
|
5381
|
+
}
|
|
5382
|
+
.chart-card h3 {
|
|
5383
|
+
font-size: 14px;
|
|
5384
|
+
font-weight: 600;
|
|
5385
|
+
margin: 0 0 16px;
|
|
5386
|
+
}
|
|
5387
|
+
.chart-grid {
|
|
5388
|
+
display: grid;
|
|
5389
|
+
grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
|
|
5390
|
+
gap: 16px;
|
|
5391
|
+
}
|
|
5392
|
+
.legend {
|
|
5393
|
+
display: flex;
|
|
5394
|
+
flex-wrap: wrap;
|
|
5395
|
+
gap: 12px;
|
|
5396
|
+
font-size: 12px;
|
|
5397
|
+
margin-top: 12px;
|
|
5398
|
+
}
|
|
5399
|
+
.legend-swatch {
|
|
5400
|
+
display: inline-block;
|
|
5401
|
+
width: 10px;
|
|
5402
|
+
height: 10px;
|
|
5403
|
+
border-radius: 2px;
|
|
5404
|
+
margin-right: 6px;
|
|
5405
|
+
vertical-align: middle;
|
|
5406
|
+
}
|
|
5407
|
+
.empty-state {
|
|
5408
|
+
background: ${COLORS.surface};
|
|
5409
|
+
border: 1px dashed ${COLORS.border};
|
|
5410
|
+
border-radius: 8px;
|
|
5411
|
+
padding: 32px;
|
|
5412
|
+
color: ${COLORS.textMuted};
|
|
5413
|
+
text-align: center;
|
|
5414
|
+
font-size: 13px;
|
|
5415
|
+
}
|
|
5416
|
+
.steps {
|
|
5417
|
+
display: grid;
|
|
5418
|
+
gap: 12px;
|
|
5419
|
+
}
|
|
5420
|
+
.step {
|
|
5421
|
+
background: ${COLORS.surface};
|
|
5422
|
+
border: 1px solid ${COLORS.border};
|
|
5423
|
+
border-radius: 8px;
|
|
5424
|
+
padding: 16px 20px;
|
|
5425
|
+
display: grid;
|
|
5426
|
+
gap: 4px;
|
|
5427
|
+
}
|
|
5428
|
+
.step .horizon {
|
|
5429
|
+
text-transform: uppercase;
|
|
5430
|
+
font-size: 10px;
|
|
5431
|
+
letter-spacing: 0.08em;
|
|
5432
|
+
color: ${COLORS.textFaint};
|
|
5433
|
+
font-weight: 600;
|
|
5434
|
+
}
|
|
5435
|
+
.step .title { font-weight: 600; }
|
|
5436
|
+
.step .rationale { color: ${COLORS.textMuted}; font-size: 13px; }
|
|
5437
|
+
.footer {
|
|
5438
|
+
margin-top: 96px;
|
|
5439
|
+
padding-top: 24px;
|
|
5440
|
+
border-top: 1px solid ${COLORS.border};
|
|
5441
|
+
text-align: center;
|
|
5442
|
+
color: ${COLORS.textFaint};
|
|
5443
|
+
font-size: 12px;
|
|
5444
|
+
}
|
|
5445
|
+
@media print {
|
|
5446
|
+
body { background: white; color: black; }
|
|
5447
|
+
section.report-section { break-inside: avoid; }
|
|
5448
|
+
}
|
|
5449
|
+
`;
|
|
5450
|
+
function section(opts, body) {
|
|
5451
|
+
return `<section class="report-section" id="${escapeHtml(opts.id)}">
|
|
5452
|
+
<div class="eyebrow">${escapeHtml(opts.eyebrow)}</div>
|
|
5453
|
+
<h2>${escapeHtml(opts.title)}</h2>
|
|
5454
|
+
${opts.intro ? `<p class="section-intro">${escapeHtml(opts.intro)}</p>` : ""}
|
|
5455
|
+
${body}
|
|
5456
|
+
</section>`;
|
|
5457
|
+
}
|
|
5458
|
+
function renderEmpty(message) {
|
|
5459
|
+
return `<div class="empty-state">${escapeHtml(message)}</div>`;
|
|
5460
|
+
}
|
|
5461
|
+
function renderExecutiveSummary(report) {
|
|
5462
|
+
const s = report.executiveSummary;
|
|
5463
|
+
const trendLabel = s.trend === "up" ? "\u2191 Up" : s.trend === "down" ? "\u2193 Down" : s.trend === "flat" ? "\u2192 Flat" : "\u2014";
|
|
5464
|
+
const trendTone = s.trend === "up" ? "positive" : s.trend === "down" ? "negative" : "neutral";
|
|
5465
|
+
const metrics = [
|
|
5466
|
+
{
|
|
5467
|
+
label: "Citation rate",
|
|
5468
|
+
value: `${s.citationRate}%`,
|
|
5469
|
+
delta: `<span class="tone-${trendTone}">${trendLabel}</span> \xB7 ${s.providerCount} provider${s.providerCount === 1 ? "" : "s"}`
|
|
5470
|
+
},
|
|
5471
|
+
{
|
|
5472
|
+
label: "Keywords tracked",
|
|
5473
|
+
value: formatNumber(s.keywordCount),
|
|
5474
|
+
delta: `${s.competitorCount} competitor${s.competitorCount === 1 ? "" : "s"} tracked`
|
|
5475
|
+
}
|
|
5476
|
+
];
|
|
5477
|
+
if (s.gsc) {
|
|
5478
|
+
metrics.push({
|
|
5479
|
+
label: "GSC clicks",
|
|
5480
|
+
value: formatNumber(s.gsc.clicks),
|
|
5481
|
+
delta: `${formatNumber(s.gsc.impressions)} imp \xB7 ${formatRatio(s.gsc.ctr)} CTR`
|
|
5482
|
+
});
|
|
5483
|
+
}
|
|
5484
|
+
if (s.ga) {
|
|
5485
|
+
metrics.push({
|
|
5486
|
+
label: "GA sessions",
|
|
5487
|
+
value: formatNumber(s.ga.sessions),
|
|
5488
|
+
delta: `${formatNumber(s.ga.users)} users \xB7 ${formatDate(s.ga.periodStart)} \u2192 ${formatDate(s.ga.periodEnd)}`
|
|
5489
|
+
});
|
|
5490
|
+
}
|
|
5491
|
+
const metricsHtml = `<div class="metric-grid">
|
|
5492
|
+
${metrics.map((m) => `<div class="metric">
|
|
5493
|
+
<div class="label">${escapeHtml(m.label)}</div>
|
|
5494
|
+
<div class="value">${m.value}</div>
|
|
5495
|
+
<div class="delta">${m.delta}</div>
|
|
5496
|
+
</div>`).join("")}
|
|
5497
|
+
</div>`;
|
|
5498
|
+
const findingsHtml = s.findings.length > 0 ? `<div class="findings">${s.findings.map((f) => `
|
|
5499
|
+
<div class="finding tone-${f.tone}">
|
|
5500
|
+
<strong>${escapeHtml(f.title)}</strong>
|
|
5501
|
+
<span>${escapeHtml(f.detail)}</span>
|
|
5502
|
+
</div>`).join("")}</div>` : "";
|
|
5503
|
+
return section(
|
|
5504
|
+
{ id: "executive-summary", eyebrow: "Section 1", title: "Executive Summary" },
|
|
5505
|
+
metricsHtml + findingsHtml
|
|
5506
|
+
);
|
|
5507
|
+
}
|
|
5508
|
+
function renderProviderBars(rates) {
|
|
5509
|
+
if (rates.length === 0) return "";
|
|
5510
|
+
const max = Math.max(...rates.map((r) => r.citationRate), 100);
|
|
5511
|
+
const width = 600;
|
|
5512
|
+
const height = Math.max(rates.length * 32 + 24, 80);
|
|
5513
|
+
const labelWidth = 80;
|
|
5514
|
+
const padding = 8;
|
|
5515
|
+
const barWidth = width - labelWidth - padding * 2;
|
|
5516
|
+
const bars = rates.map((r, i) => {
|
|
5517
|
+
const y = i * 32 + padding;
|
|
5518
|
+
const barHeight = 22;
|
|
5519
|
+
const w = max > 0 ? r.citationRate / max * barWidth : 0;
|
|
5520
|
+
const color = COLORS.series[i % COLORS.series.length];
|
|
5521
|
+
return `
|
|
5522
|
+
<text x="${labelWidth - 8}" y="${y + 16}" fill="${COLORS.textMuted}" font-size="11" text-anchor="end">${escapeHtml(r.provider)}</text>
|
|
5523
|
+
<rect x="${labelWidth}" y="${y}" width="${barWidth}" height="${barHeight}" fill="${COLORS.border}" opacity="0.4" rx="3" />
|
|
5524
|
+
<rect x="${labelWidth}" y="${y}" width="${w}" height="${barHeight}" fill="${color}" rx="3" />
|
|
5525
|
+
<text x="${labelWidth + w + 6}" y="${y + 16}" fill="${COLORS.text}" font-size="11">${r.citationRate}% (${r.citedCount}/${r.totalCount})</text>`;
|
|
5526
|
+
}).join("");
|
|
5527
|
+
return `<div class="chart-card">
|
|
5528
|
+
<h3>Provider citation rate</h3>
|
|
5529
|
+
<svg viewBox="0 0 ${width} ${height}" width="100%" preserveAspectRatio="xMinYMin meet" role="img" aria-label="Provider citation rate bar chart">
|
|
5530
|
+
${bars}
|
|
5531
|
+
</svg>
|
|
5532
|
+
</div>`;
|
|
5533
|
+
}
|
|
5534
|
+
function renderCitationMatrix(scorecard) {
|
|
5535
|
+
if (scorecard.keywords.length === 0 || scorecard.providers.length === 0) {
|
|
5536
|
+
return renderEmpty("Run a visibility sweep to populate the citation matrix.");
|
|
5537
|
+
}
|
|
5538
|
+
const headers = scorecard.providers.map((p) => `<th>${escapeHtml(p)}</th>`).join("");
|
|
5539
|
+
const rows = scorecard.keywords.map((kw, ki) => {
|
|
5540
|
+
const cells = scorecard.providers.map((_, pi) => {
|
|
5541
|
+
const cell = scorecard.matrix[ki]?.[pi];
|
|
5542
|
+
if (!cell) {
|
|
5543
|
+
return '<td><span class="cell-pending">\u2014</span></td>';
|
|
5544
|
+
}
|
|
5545
|
+
if (cell.citationState === "cited") {
|
|
5546
|
+
return '<td><span class="cell-cited">Cited</span></td>';
|
|
5547
|
+
}
|
|
5548
|
+
return '<td><span class="cell-not-cited">Not cited</span></td>';
|
|
5549
|
+
}).join("");
|
|
5550
|
+
return `<tr><td>${escapeHtml(kw)}</td>${cells}</tr>`;
|
|
5551
|
+
}).join("");
|
|
5552
|
+
return `<table class="report-table">
|
|
5553
|
+
<thead><tr><th>Keyword</th>${headers}</tr></thead>
|
|
5554
|
+
<tbody>${rows}</tbody>
|
|
5555
|
+
</table>`;
|
|
5556
|
+
}
|
|
5557
|
+
function renderCitationScorecard(report) {
|
|
5558
|
+
const body = `
|
|
5559
|
+
${renderProviderBars(report.citationScorecard.providerRates)}
|
|
5560
|
+
${renderCitationMatrix(report.citationScorecard)}
|
|
5561
|
+
`;
|
|
5562
|
+
return section(
|
|
5563
|
+
{ id: "citation-scorecard", eyebrow: "Section 2", title: "Citation Scorecard", intro: "Per-keyword \xD7 per-provider citation matrix from the latest visibility sweep." },
|
|
5564
|
+
body
|
|
5565
|
+
);
|
|
5566
|
+
}
|
|
5567
|
+
function renderCompetitorBars(landscape, canonical) {
|
|
5568
|
+
const data = [
|
|
5569
|
+
{ label: canonical, count: landscape.projectCitationCount, isProject: true },
|
|
5570
|
+
...landscape.competitors.map((c) => ({ label: c.domain, count: c.citationCount, isProject: false }))
|
|
5571
|
+
];
|
|
5572
|
+
if (data.length <= 1) return "";
|
|
5573
|
+
const max = Math.max(...data.map((d) => d.count), 1);
|
|
5574
|
+
const width = 600;
|
|
5575
|
+
const height = data.length * 28 + 16;
|
|
5576
|
+
const labelWidth = 160;
|
|
5577
|
+
const bars = data.map((d, i) => {
|
|
5578
|
+
const y = i * 28 + 8;
|
|
5579
|
+
const barHeight = 18;
|
|
5580
|
+
const w = d.count / max * (width - labelWidth - 60);
|
|
5581
|
+
const color = d.isProject ? COLORS.accent : COLORS.series[(i + 1) % COLORS.series.length];
|
|
5582
|
+
return `
|
|
5583
|
+
<text x="${labelWidth - 8}" y="${y + 13}" fill="${COLORS.textMuted}" font-size="11" text-anchor="end">${escapeHtml(d.label)}</text>
|
|
5584
|
+
<rect x="${labelWidth}" y="${y}" width="${w}" height="${barHeight}" fill="${color}" rx="3" />
|
|
5585
|
+
<text x="${labelWidth + w + 6}" y="${y + 13}" fill="${COLORS.text}" font-size="11">${d.count}</text>`;
|
|
5586
|
+
}).join("");
|
|
5587
|
+
return `<div class="chart-card">
|
|
5588
|
+
<h3>Citations per domain</h3>
|
|
5589
|
+
<svg viewBox="0 0 ${width} ${height}" width="100%" preserveAspectRatio="xMinYMin meet" role="img" aria-label="Citations per domain bar chart">
|
|
5590
|
+
${bars}
|
|
5591
|
+
</svg>
|
|
5592
|
+
</div>`;
|
|
5593
|
+
}
|
|
5594
|
+
function renderCompetitorLandscape(report) {
|
|
5595
|
+
const competitors2 = report.competitorLandscape.competitors;
|
|
5596
|
+
if (competitors2.length === 0 && report.competitorLandscape.projectCitationCount === 0) {
|
|
5597
|
+
return section(
|
|
5598
|
+
{ id: "competitor-landscape", eyebrow: "Section 3", title: "Competitor Landscape" },
|
|
5599
|
+
renderEmpty("No competitor data yet. Add competitors and run a visibility sweep.")
|
|
5600
|
+
);
|
|
5601
|
+
}
|
|
5602
|
+
const rows = competitors2.map((c) => {
|
|
5603
|
+
const tone = pressureTone(c.pressureLabel);
|
|
5604
|
+
return `<tr>
|
|
5605
|
+
<td>${escapeHtml(c.domain)}</td>
|
|
5606
|
+
<td><span class="badge tone-${tone}">${escapeHtml(c.pressureLabel)}</span></td>
|
|
5607
|
+
<td class="numeric">${c.citationCount} / ${c.totalCount}</td>
|
|
5608
|
+
<td>${escapeHtml(c.citedKeywords.slice(0, 5).join(", "))}${c.citedKeywords.length > 5 ? "\u2026" : ""}</td>
|
|
5609
|
+
</tr>`;
|
|
5610
|
+
}).join("");
|
|
5611
|
+
const table = competitors2.length > 0 ? `<table class="report-table">
|
|
5612
|
+
<thead><tr><th>Domain</th><th>Pressure</th><th>Citations</th><th>Cited keywords</th></tr></thead>
|
|
5613
|
+
<tbody>${rows}</tbody>
|
|
5614
|
+
</table>` : renderEmpty("No competitors configured.");
|
|
5615
|
+
return section(
|
|
5616
|
+
{ id: "competitor-landscape", eyebrow: "Section 3", title: "Competitor Landscape", intro: "Where tracked competitors appear in AI answers compared to your domain." },
|
|
5617
|
+
`${renderCompetitorBars(report.competitorLandscape, report.meta.project.canonicalDomain)}${table}`
|
|
5618
|
+
);
|
|
5619
|
+
}
|
|
5620
|
+
function renderDonut(buckets) {
|
|
5621
|
+
if (buckets.length === 0) return "";
|
|
5622
|
+
const total = buckets.reduce((s, b) => s + b.count, 0);
|
|
5623
|
+
if (total === 0) return "";
|
|
5624
|
+
const cx = 110;
|
|
5625
|
+
const cy = 110;
|
|
5626
|
+
const r = 80;
|
|
5627
|
+
const innerR = 48;
|
|
5628
|
+
let cumulative = 0;
|
|
5629
|
+
const slices = [];
|
|
5630
|
+
const legend = [];
|
|
5631
|
+
buckets.forEach((b, i) => {
|
|
5632
|
+
const startAngle = cumulative / total * Math.PI * 2 - Math.PI / 2;
|
|
5633
|
+
const endAngle = (cumulative + b.count) / total * Math.PI * 2 - Math.PI / 2;
|
|
5634
|
+
cumulative += b.count;
|
|
5635
|
+
const x1 = cx + Math.cos(startAngle) * r;
|
|
5636
|
+
const y1 = cy + Math.sin(startAngle) * r;
|
|
5637
|
+
const x2 = cx + Math.cos(endAngle) * r;
|
|
5638
|
+
const y2 = cy + Math.sin(endAngle) * r;
|
|
5639
|
+
const ix1 = cx + Math.cos(endAngle) * innerR;
|
|
5640
|
+
const iy1 = cy + Math.sin(endAngle) * innerR;
|
|
5641
|
+
const ix2 = cx + Math.cos(startAngle) * innerR;
|
|
5642
|
+
const iy2 = cy + Math.sin(startAngle) * innerR;
|
|
5643
|
+
const largeArc = endAngle - startAngle > Math.PI ? 1 : 0;
|
|
5644
|
+
const color = COLORS.series[i % COLORS.series.length];
|
|
5645
|
+
if (b.count > 0) {
|
|
5646
|
+
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}" />`);
|
|
5647
|
+
legend.push(`<span><span class="legend-swatch" style="background:${color}"></span>${escapeHtml(b.label)} (${b.count})</span>`);
|
|
5648
|
+
}
|
|
5649
|
+
});
|
|
5650
|
+
return `<div class="chart-card">
|
|
5651
|
+
<h3>AI source categories</h3>
|
|
5652
|
+
<div style="display:flex;align-items:center;gap:24px;flex-wrap:wrap;">
|
|
5653
|
+
<svg viewBox="0 0 220 220" width="220" height="220" role="img" aria-label="AI source category donut chart">
|
|
5654
|
+
${slices.join("")}
|
|
5655
|
+
</svg>
|
|
5656
|
+
<div class="legend" style="flex-direction:column;align-items:flex-start;gap:6px;">${legend.join("")}</div>
|
|
5657
|
+
</div>
|
|
5658
|
+
</div>`;
|
|
5659
|
+
}
|
|
5660
|
+
function renderAiSourceOrigin(report) {
|
|
5661
|
+
const origin = report.aiSourceOrigin;
|
|
5662
|
+
if (origin.categories.length === 0 && origin.topDomains.length === 0) {
|
|
5663
|
+
return section(
|
|
5664
|
+
{ id: "ai-source-origin", eyebrow: "Section 4", title: "AI Source Origin" },
|
|
5665
|
+
renderEmpty("No source data yet. Run a visibility sweep first.")
|
|
5666
|
+
);
|
|
5667
|
+
}
|
|
5668
|
+
const rows = origin.topDomains.map((d) => `
|
|
5669
|
+
<tr>
|
|
5670
|
+
<td>${escapeHtml(d.domain)}</td>
|
|
5671
|
+
<td class="numeric">${d.count}</td>
|
|
5672
|
+
<td>${d.isCompetitor ? '<span class="badge tone-negative">Competitor</span>' : '<span class="badge tone-neutral">External</span>'}</td>
|
|
5673
|
+
</tr>`).join("");
|
|
5674
|
+
const table = origin.topDomains.length > 0 ? `<table class="report-table">
|
|
5675
|
+
<thead><tr><th>Domain</th><th>Citations</th><th>Tag</th></tr></thead>
|
|
5676
|
+
<tbody>${rows}</tbody>
|
|
5677
|
+
</table>` : "";
|
|
5678
|
+
return section(
|
|
5679
|
+
{ id: "ai-source-origin", eyebrow: "Section 4", title: "AI Source Origin", intro: "Where AI answers pull from, aggregated across the latest sweep." },
|
|
5680
|
+
`${renderDonut(origin.categories)}${table}`
|
|
5681
|
+
);
|
|
5682
|
+
}
|
|
5683
|
+
function renderLineChart(points, color, title, height = 200) {
|
|
5684
|
+
if (points.length === 0) return "";
|
|
5685
|
+
const width = 600;
|
|
5686
|
+
const padX = 32;
|
|
5687
|
+
const padY = 24;
|
|
5688
|
+
const usableW = width - padX * 2;
|
|
5689
|
+
const usableH = height - padY * 2;
|
|
5690
|
+
const max = Math.max(...points.map((p) => p.y), 1);
|
|
5691
|
+
const stepX = points.length > 1 ? usableW / (points.length - 1) : 0;
|
|
5692
|
+
const xy = points.map((p, i) => ({
|
|
5693
|
+
x: padX + i * stepX,
|
|
5694
|
+
y: padY + usableH - p.y / max * usableH,
|
|
5695
|
+
raw: p
|
|
5696
|
+
}));
|
|
5697
|
+
const path10 = xy.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x.toFixed(1)} ${p.y.toFixed(1)}`).join(" ");
|
|
5698
|
+
const dots = xy.map((p) => `<circle cx="${p.x.toFixed(1)}" cy="${p.y.toFixed(1)}" r="3" fill="${color}" />`).join("");
|
|
5699
|
+
const xLabels = xy.map((p, i) => {
|
|
5700
|
+
if (points.length > 8 && i % Math.ceil(points.length / 6) !== 0 && i !== points.length - 1) return "";
|
|
5701
|
+
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>`;
|
|
5702
|
+
}).join("");
|
|
5703
|
+
return `<div class="chart-card">
|
|
5704
|
+
<h3>${escapeHtml(title)}</h3>
|
|
5705
|
+
<svg viewBox="0 0 ${width} ${height}" width="100%" preserveAspectRatio="xMinYMin meet" role="img" aria-label="${escapeHtml(title)} line chart">
|
|
5706
|
+
<line x1="${padX}" y1="${padY + usableH}" x2="${padX + usableW}" y2="${padY + usableH}" stroke="${COLORS.border}" stroke-width="1" />
|
|
5707
|
+
<text x="${padX - 6}" y="${(padY + 4).toFixed(1)}" fill="${COLORS.textFaint}" font-size="9" text-anchor="end">${formatNumber(max)}</text>
|
|
5708
|
+
<text x="${padX - 6}" y="${(padY + usableH).toFixed(1)}" fill="${COLORS.textFaint}" font-size="9" text-anchor="end">0</text>
|
|
5709
|
+
<path d="${path10}" stroke="${color}" stroke-width="2" fill="none" />
|
|
5710
|
+
${dots}
|
|
5711
|
+
${xLabels}
|
|
5712
|
+
</svg>
|
|
5713
|
+
</div>`;
|
|
5714
|
+
}
|
|
5715
|
+
function renderGsc(report) {
|
|
5716
|
+
const gsc = report.gsc;
|
|
5717
|
+
if (!gsc) {
|
|
5718
|
+
return section(
|
|
5719
|
+
{ id: "gsc", eyebrow: "Section 5", title: "GSC Performance" },
|
|
5720
|
+
renderEmpty("Connect Google Search Console to populate this section.")
|
|
5721
|
+
);
|
|
5722
|
+
}
|
|
5723
|
+
const rows = gsc.topQueries.map((q) => `
|
|
5724
|
+
<tr>
|
|
5725
|
+
<td>${escapeHtml(q.query)}</td>
|
|
5726
|
+
<td class="numeric">${formatNumber(q.clicks)}</td>
|
|
5727
|
+
<td class="numeric">${formatNumber(q.impressions)}</td>
|
|
5728
|
+
<td class="numeric">${formatRatio(q.ctr)}</td>
|
|
5729
|
+
<td class="numeric">${q.avgPosition.toFixed(1)}</td>
|
|
5730
|
+
<td><span class="badge tone-neutral">${escapeHtml(q.category)}</span></td>
|
|
5731
|
+
</tr>`).join("");
|
|
5732
|
+
const breakdownRows = gsc.categoryBreakdown.map((c) => `
|
|
5733
|
+
<tr>
|
|
5734
|
+
<td>${escapeHtml(c.category)}</td>
|
|
5735
|
+
<td class="numeric">${formatNumber(c.clicks)}</td>
|
|
5736
|
+
<td class="numeric">${formatNumber(c.impressions)}</td>
|
|
5737
|
+
<td class="numeric">${c.sharePct}%</td>
|
|
5738
|
+
</tr>`).join("");
|
|
5739
|
+
const trendChart = renderLineChart(
|
|
5740
|
+
gsc.trend.map((t) => ({ x: t.date, y: t.clicks, label: t.date.slice(5) })),
|
|
5741
|
+
COLORS.accent,
|
|
5742
|
+
"Clicks over time"
|
|
5743
|
+
);
|
|
5744
|
+
return section(
|
|
5745
|
+
{ id: "gsc", eyebrow: "Section 5", title: "GSC Performance", intro: "Top queries, category breakdown, and traffic trend from Google Search Console." },
|
|
5746
|
+
`<div class="metric-grid">
|
|
5747
|
+
<div class="metric"><div class="label">Total clicks</div><div class="value">${formatNumber(gsc.totalClicks)}</div></div>
|
|
5748
|
+
<div class="metric"><div class="label">Total impressions</div><div class="value">${formatNumber(gsc.totalImpressions)}</div></div>
|
|
5749
|
+
<div class="metric"><div class="label">Avg CTR</div><div class="value">${formatRatio(gsc.ctr)}</div></div>
|
|
5750
|
+
<div class="metric"><div class="label">Avg position</div><div class="value">${gsc.avgPosition.toFixed(1)}</div></div>
|
|
5751
|
+
</div>
|
|
5752
|
+
${trendChart}
|
|
5753
|
+
<div class="chart-card"><h3>Top queries</h3>
|
|
5754
|
+
<table class="report-table">
|
|
5755
|
+
<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>
|
|
5756
|
+
<tbody>${rows}</tbody>
|
|
5757
|
+
</table>
|
|
5758
|
+
</div>
|
|
5759
|
+
<div class="chart-card"><h3>Category breakdown</h3>
|
|
5760
|
+
<table class="report-table">
|
|
5761
|
+
<thead><tr><th>Category</th><th class="numeric">Clicks</th><th class="numeric">Imp.</th><th class="numeric">Share</th></tr></thead>
|
|
5762
|
+
<tbody>${breakdownRows}</tbody>
|
|
5763
|
+
</table>
|
|
5764
|
+
</div>`
|
|
5765
|
+
);
|
|
5766
|
+
}
|
|
5767
|
+
function renderGa(report) {
|
|
5768
|
+
const ga = report.ga;
|
|
5769
|
+
if (!ga) {
|
|
5770
|
+
return section(
|
|
5771
|
+
{ id: "ga", eyebrow: "Section 6", title: "GA4 Traffic" },
|
|
5772
|
+
renderEmpty("Connect Google Analytics 4 to populate this section.")
|
|
5773
|
+
);
|
|
5774
|
+
}
|
|
5775
|
+
const pageRows = ga.topLandingPages.map((p) => `
|
|
5776
|
+
<tr>
|
|
5777
|
+
<td>${escapeHtml(p.page)}</td>
|
|
5778
|
+
<td class="numeric">${formatNumber(p.sessions)}</td>
|
|
5779
|
+
<td class="numeric">${formatNumber(p.users)}</td>
|
|
5780
|
+
<td class="numeric">${formatNumber(p.organicSessions)}</td>
|
|
5781
|
+
</tr>`).join("");
|
|
5782
|
+
const channelRows = ga.channelBreakdown.map((c) => `
|
|
5783
|
+
<tr>
|
|
5784
|
+
<td>${escapeHtml(c.channel)}</td>
|
|
5785
|
+
<td class="numeric">${formatNumber(c.sessions)}</td>
|
|
5786
|
+
<td class="numeric">${c.sharePct}%</td>
|
|
5787
|
+
</tr>`).join("");
|
|
5788
|
+
return section(
|
|
5789
|
+
{ id: "ga", eyebrow: "Section 6", title: "GA4 Traffic", intro: `Sessions and users for ${formatDate(ga.periodStart)} \u2192 ${formatDate(ga.periodEnd)}.` },
|
|
5790
|
+
`<div class="metric-grid">
|
|
5791
|
+
<div class="metric"><div class="label">Total sessions</div><div class="value">${formatNumber(ga.totalSessions)}</div></div>
|
|
5792
|
+
<div class="metric"><div class="label">Total users</div><div class="value">${formatNumber(ga.totalUsers)}</div></div>
|
|
5793
|
+
<div class="metric"><div class="label">Organic sessions</div><div class="value">${formatNumber(ga.totalOrganicSessions)}</div></div>
|
|
5794
|
+
</div>
|
|
5795
|
+
<div class="chart-card"><h3>Top landing pages</h3>
|
|
5796
|
+
<table class="report-table">
|
|
5797
|
+
<thead><tr><th>Page</th><th class="numeric">Sessions</th><th class="numeric">Users</th><th class="numeric">Organic</th></tr></thead>
|
|
5798
|
+
<tbody>${pageRows}</tbody>
|
|
5799
|
+
</table>
|
|
5800
|
+
</div>
|
|
5801
|
+
<div class="chart-card"><h3>Channel breakdown</h3>
|
|
5802
|
+
<table class="report-table">
|
|
5803
|
+
<thead><tr><th>Channel</th><th class="numeric">Sessions</th><th class="numeric">Share</th></tr></thead>
|
|
5804
|
+
<tbody>${channelRows}</tbody>
|
|
5805
|
+
</table>
|
|
5806
|
+
</div>`
|
|
5807
|
+
);
|
|
5808
|
+
}
|
|
5809
|
+
function renderSocial(report) {
|
|
5810
|
+
const social = report.socialReferrals;
|
|
5811
|
+
if (!social) {
|
|
5812
|
+
return section(
|
|
5813
|
+
{ id: "social-referrals", eyebrow: "Section 7", title: "Social Referrals" },
|
|
5814
|
+
renderEmpty("No social referral data yet.")
|
|
5815
|
+
);
|
|
5816
|
+
}
|
|
5817
|
+
const channelRows = social.channels.map((c) => `
|
|
5818
|
+
<tr>
|
|
5819
|
+
<td>${escapeHtml(c.channelGroup)}</td>
|
|
5820
|
+
<td class="numeric">${formatNumber(c.sessions)}</td>
|
|
5821
|
+
<td class="numeric">${c.sharePct}%</td>
|
|
5822
|
+
</tr>`).join("");
|
|
5823
|
+
const campaignRows = social.topCampaigns.map((c) => `
|
|
5824
|
+
<tr>
|
|
5825
|
+
<td>${escapeHtml(c.source)}</td>
|
|
5826
|
+
<td>${escapeHtml(c.medium)}</td>
|
|
5827
|
+
<td class="numeric">${formatNumber(c.sessions)}</td>
|
|
5828
|
+
</tr>`).join("");
|
|
5829
|
+
return section(
|
|
5830
|
+
{ id: "social-referrals", eyebrow: "Section 7", title: "Social Referrals", intro: "Paid vs organic split with top campaigns." },
|
|
5831
|
+
`<div class="metric-grid">
|
|
5832
|
+
<div class="metric"><div class="label">Total sessions</div><div class="value">${formatNumber(social.totalSessions)}</div></div>
|
|
5833
|
+
<div class="metric"><div class="label">Organic social</div><div class="value">${formatNumber(social.organicSessions)}</div></div>
|
|
5834
|
+
<div class="metric"><div class="label">Paid social</div><div class="value">${formatNumber(social.paidSessions)}</div></div>
|
|
5835
|
+
</div>
|
|
5836
|
+
<div class="chart-card"><h3>Channel groups</h3>
|
|
5837
|
+
<table class="report-table">
|
|
5838
|
+
<thead><tr><th>Channel</th><th class="numeric">Sessions</th><th class="numeric">Share</th></tr></thead>
|
|
5839
|
+
<tbody>${channelRows}</tbody>
|
|
5840
|
+
</table>
|
|
5841
|
+
</div>
|
|
5842
|
+
<div class="chart-card"><h3>Top campaigns</h3>
|
|
5843
|
+
<table class="report-table">
|
|
5844
|
+
<thead><tr><th>Source</th><th>Medium</th><th class="numeric">Sessions</th></tr></thead>
|
|
5845
|
+
<tbody>${campaignRows}</tbody>
|
|
5846
|
+
</table>
|
|
5847
|
+
</div>`
|
|
5848
|
+
);
|
|
5849
|
+
}
|
|
5850
|
+
function renderAiReferrals(report) {
|
|
5851
|
+
const ai = report.aiReferrals;
|
|
5852
|
+
if (!ai) {
|
|
5853
|
+
return section(
|
|
5854
|
+
{ id: "ai-referrals", eyebrow: "Section 8", title: "AI Referral Traffic" },
|
|
5855
|
+
renderEmpty("No AI referral traffic detected yet.")
|
|
5856
|
+
);
|
|
5857
|
+
}
|
|
5858
|
+
const sourceRows = ai.bySource.map((s) => `
|
|
5859
|
+
<tr>
|
|
5860
|
+
<td>${escapeHtml(s.source)}</td>
|
|
5861
|
+
<td class="numeric">${formatNumber(s.sessions)}</td>
|
|
5862
|
+
<td class="numeric">${formatNumber(s.users)}</td>
|
|
5863
|
+
<td class="numeric">${s.sharePct}%</td>
|
|
5864
|
+
</tr>`).join("");
|
|
5865
|
+
const pageRows = ai.topLandingPages.map((p) => `
|
|
5866
|
+
<tr>
|
|
5867
|
+
<td>${escapeHtml(p.page)}</td>
|
|
5868
|
+
<td class="numeric">${formatNumber(p.sessions)}</td>
|
|
5869
|
+
<td class="numeric">${formatNumber(p.users)}</td>
|
|
5870
|
+
</tr>`).join("");
|
|
5871
|
+
const trendChart = renderLineChart(
|
|
5872
|
+
ai.trend.map((t) => ({ x: t.date, y: t.sessions, label: t.date.slice(5) })),
|
|
5873
|
+
COLORS.series[2],
|
|
5874
|
+
"AI referral sessions over time"
|
|
5875
|
+
);
|
|
5876
|
+
return section(
|
|
5877
|
+
{ id: "ai-referrals", eyebrow: "Section 8", title: "AI Referral Traffic", intro: "Sessions sent from AI answer engines." },
|
|
5878
|
+
`<div class="metric-grid">
|
|
5879
|
+
<div class="metric"><div class="label">Total sessions</div><div class="value">${formatNumber(ai.totalSessions)}</div></div>
|
|
5880
|
+
<div class="metric"><div class="label">Total users</div><div class="value">${formatNumber(ai.totalUsers)}</div></div>
|
|
5881
|
+
</div>
|
|
5882
|
+
${trendChart}
|
|
5883
|
+
<div class="chart-card"><h3>Sessions by source</h3>
|
|
5884
|
+
<table class="report-table">
|
|
5885
|
+
<thead><tr><th>Source</th><th class="numeric">Sessions</th><th class="numeric">Users</th><th class="numeric">Share</th></tr></thead>
|
|
5886
|
+
<tbody>${sourceRows}</tbody>
|
|
5887
|
+
</table>
|
|
5888
|
+
</div>
|
|
5889
|
+
<div class="chart-card"><h3>Top AI landing pages</h3>
|
|
5890
|
+
<table class="report-table">
|
|
5891
|
+
<thead><tr><th>Page</th><th class="numeric">Sessions</th><th class="numeric">Users</th></tr></thead>
|
|
5892
|
+
<tbody>${pageRows}</tbody>
|
|
5893
|
+
</table>
|
|
5894
|
+
</div>`
|
|
5895
|
+
);
|
|
5896
|
+
}
|
|
5897
|
+
function renderIndexingHealth(report) {
|
|
5898
|
+
const ih = report.indexingHealth;
|
|
5899
|
+
if (!ih) {
|
|
5900
|
+
return section(
|
|
5901
|
+
{ id: "indexing-health", eyebrow: "Section 9", title: "Indexing Health" },
|
|
5902
|
+
renderEmpty("Connect Google Search Console or Bing Webmaster Tools and run a sitemap inspection.")
|
|
5903
|
+
);
|
|
5904
|
+
}
|
|
5905
|
+
const segments = [
|
|
5906
|
+
{ label: "Indexed", count: ih.indexed, color: COLORS.positive },
|
|
5907
|
+
{ label: "Not indexed", count: ih.notIndexed, color: COLORS.caution },
|
|
5908
|
+
{ label: "Deindexed", count: ih.deindexed, color: COLORS.negative },
|
|
5909
|
+
{ label: "Unknown", count: ih.unknown, color: COLORS.neutral }
|
|
5910
|
+
].filter((s) => s.count > 0);
|
|
5911
|
+
const total = segments.reduce((s, x) => s + x.count, 0) || 1;
|
|
5912
|
+
const width = 600;
|
|
5913
|
+
const height = 28;
|
|
5914
|
+
let acc = 0;
|
|
5915
|
+
const bars = segments.map((s) => {
|
|
5916
|
+
const w = s.count / total * width;
|
|
5917
|
+
const x = acc;
|
|
5918
|
+
acc += w;
|
|
5919
|
+
return `<rect x="${x}" y="0" width="${w}" height="${height}" fill="${s.color}" />`;
|
|
5920
|
+
}).join("");
|
|
5921
|
+
const legend = segments.map((s) => `<span><span class="legend-swatch" style="background:${s.color}"></span>${escapeHtml(s.label)}: ${s.count}</span>`).join("");
|
|
5922
|
+
return section(
|
|
5923
|
+
{ id: "indexing-health", eyebrow: "Section 9", title: "Indexing Health", intro: `Source: ${ih.provider === "google" ? "Google Search Console" : "Bing Webmaster Tools"}.` },
|
|
5924
|
+
`<div class="metric-grid">
|
|
5925
|
+
<div class="metric"><div class="label">Indexed</div><div class="value tone-positive">${formatNumber(ih.indexed)}</div></div>
|
|
5926
|
+
<div class="metric"><div class="label">Total inspected</div><div class="value">${formatNumber(ih.total)}</div></div>
|
|
5927
|
+
<div class="metric"><div class="label">Indexed share</div><div class="value">${ih.indexedPct}%</div></div>
|
|
5928
|
+
</div>
|
|
5929
|
+
<div class="chart-card">
|
|
5930
|
+
<h3>Coverage breakdown</h3>
|
|
5931
|
+
<svg viewBox="0 0 ${width} ${height}" width="100%" preserveAspectRatio="xMinYMin meet" role="img" aria-label="Coverage stacked bar">${bars}</svg>
|
|
5932
|
+
<div class="legend">${legend}</div>
|
|
5933
|
+
</div>`
|
|
5934
|
+
);
|
|
5935
|
+
}
|
|
5936
|
+
function renderCitationsTrend(report) {
|
|
5937
|
+
const trend = report.citationsTrend;
|
|
5938
|
+
if (trend.length === 0) {
|
|
5939
|
+
return section(
|
|
5940
|
+
{ id: "citations-trend", eyebrow: "Section 10", title: "Citations Over Time" },
|
|
5941
|
+
renderEmpty("Run multiple visibility sweeps to see a trend.")
|
|
5942
|
+
);
|
|
5943
|
+
}
|
|
5944
|
+
const chart = renderLineChart(
|
|
5945
|
+
trend.map((t) => ({ x: t.date, y: t.citationRate, label: formatDate(t.date) })),
|
|
5946
|
+
COLORS.positive,
|
|
5947
|
+
"Overall citation rate",
|
|
5948
|
+
220
|
|
5949
|
+
);
|
|
5950
|
+
const rows = trend.map((t) => `
|
|
5951
|
+
<tr>
|
|
5952
|
+
<td>${formatDate(t.date)}</td>
|
|
5953
|
+
<td class="numeric">${t.citationRate}%</td>
|
|
5954
|
+
<td>${t.providerRates.map((r) => `${escapeHtml(r.provider)}: ${r.citationRate}%`).join(" \xB7 ")}</td>
|
|
5955
|
+
</tr>`).join("");
|
|
5956
|
+
return section(
|
|
5957
|
+
{ id: "citations-trend", eyebrow: "Section 10", title: "Citations Over Time", intro: "Per-run citation rate across the project history." },
|
|
5958
|
+
`${chart}
|
|
5959
|
+
<div class="chart-card"><h3>Run-by-run breakdown</h3>
|
|
5960
|
+
<table class="report-table">
|
|
5961
|
+
<thead><tr><th>Run</th><th class="numeric">Overall rate</th><th>Per-provider rates</th></tr></thead>
|
|
5962
|
+
<tbody>${rows}</tbody>
|
|
5963
|
+
</table>
|
|
5964
|
+
</div>`
|
|
5965
|
+
);
|
|
5966
|
+
}
|
|
5967
|
+
function renderInsights(report) {
|
|
5968
|
+
const list = report.insights;
|
|
5969
|
+
if (list.length === 0) {
|
|
5970
|
+
return section(
|
|
5971
|
+
{ id: "insights", eyebrow: "Section 11", title: "Insights & Alerts" },
|
|
5972
|
+
renderEmpty("No insights yet \u2014 run a visibility sweep to generate alerts.")
|
|
5973
|
+
);
|
|
5974
|
+
}
|
|
5975
|
+
const rows = list.map((i) => {
|
|
5976
|
+
const tone = severityTone(i.severity);
|
|
5977
|
+
return `<tr>
|
|
5978
|
+
<td><span class="badge tone-${tone}">${escapeHtml(i.severity)}</span></td>
|
|
5979
|
+
<td>${escapeHtml(i.title)}</td>
|
|
5980
|
+
<td>${escapeHtml(i.keyword)}</td>
|
|
5981
|
+
<td>${escapeHtml(i.provider)}</td>
|
|
5982
|
+
<td>${i.recommendation ? escapeHtml(i.recommendation) : '<span class="cell-pending">\u2014</span>'}</td>
|
|
5983
|
+
</tr>`;
|
|
5984
|
+
}).join("");
|
|
5985
|
+
return section(
|
|
5986
|
+
{ id: "insights", eyebrow: "Section 11", title: "Insights & Alerts", intro: "Priority-ordered findings from the most recent runs." },
|
|
5987
|
+
`<table class="report-table">
|
|
5988
|
+
<thead><tr><th>Severity</th><th>Title</th><th>Keyword</th><th>Provider</th><th>Recommendation</th></tr></thead>
|
|
5989
|
+
<tbody>${rows}</tbody>
|
|
5990
|
+
</table>`
|
|
5991
|
+
);
|
|
5992
|
+
}
|
|
5993
|
+
function renderRecommendedNextSteps(report) {
|
|
5994
|
+
const steps = report.recommendedNextSteps;
|
|
5995
|
+
if (steps.length === 0) {
|
|
5996
|
+
return section(
|
|
5997
|
+
{ id: "recommended-next-steps", eyebrow: "Section 12", title: "Recommended Next Steps" },
|
|
5998
|
+
renderEmpty("No outstanding actions.")
|
|
5999
|
+
);
|
|
6000
|
+
}
|
|
6001
|
+
const items = steps.map((s) => `
|
|
6002
|
+
<div class="step">
|
|
6003
|
+
<span class="horizon">${escapeHtml(s.horizon)}</span>
|
|
6004
|
+
<span class="title">${escapeHtml(s.title)}</span>
|
|
6005
|
+
<span class="rationale">${escapeHtml(s.rationale)}</span>
|
|
6006
|
+
</div>`).join("");
|
|
6007
|
+
return section(
|
|
6008
|
+
{ id: "recommended-next-steps", eyebrow: "Section 12", title: "Recommended Next Steps" },
|
|
6009
|
+
`<div class="steps">${items}</div>`
|
|
6010
|
+
);
|
|
6011
|
+
}
|
|
6012
|
+
function escapeJsonForScript(json) {
|
|
6013
|
+
return json.replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
|
|
6014
|
+
}
|
|
6015
|
+
function renderReportHtml(report, opts = {}) {
|
|
6016
|
+
const title = opts.title ?? `Canonry report \u2014 ${report.meta.project.displayName}`;
|
|
6017
|
+
const sections = [
|
|
6018
|
+
renderExecutiveSummary(report),
|
|
6019
|
+
renderCitationScorecard(report),
|
|
6020
|
+
renderCompetitorLandscape(report),
|
|
6021
|
+
renderAiSourceOrigin(report),
|
|
6022
|
+
renderGsc(report),
|
|
6023
|
+
renderGa(report),
|
|
6024
|
+
renderSocial(report),
|
|
6025
|
+
renderAiReferrals(report),
|
|
6026
|
+
renderIndexingHealth(report),
|
|
6027
|
+
renderCitationsTrend(report),
|
|
6028
|
+
renderInsights(report),
|
|
6029
|
+
renderRecommendedNextSteps(report)
|
|
6030
|
+
].join("\n");
|
|
6031
|
+
const json = escapeJsonForScript(JSON.stringify(report));
|
|
6032
|
+
return `<!DOCTYPE html>
|
|
6033
|
+
<html lang="en">
|
|
6034
|
+
<head>
|
|
6035
|
+
<meta charset="utf-8" />
|
|
6036
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6037
|
+
<title>${escapeHtml(title)}</title>
|
|
6038
|
+
<style>${STYLE}</style>
|
|
6039
|
+
</head>
|
|
6040
|
+
<body>
|
|
6041
|
+
<div class="container">
|
|
6042
|
+
<header class="header">
|
|
6043
|
+
<div class="eyebrow">AEO Report</div>
|
|
6044
|
+
<h1>${escapeHtml(report.meta.project.displayName)}</h1>
|
|
6045
|
+
<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>
|
|
6046
|
+
</header>
|
|
6047
|
+
${sections}
|
|
6048
|
+
<footer class="footer">Generated by canonry \xB7 ${escapeHtml(report.meta.generatedAt)}</footer>
|
|
6049
|
+
</div>
|
|
6050
|
+
<script type="application/json" id="canonry-report-data">${json}</script>
|
|
6051
|
+
</body>
|
|
6052
|
+
</html>`;
|
|
6053
|
+
}
|
|
6054
|
+
|
|
6055
|
+
// src/commands/report.ts
|
|
6056
|
+
function defaultOutputPath(project) {
|
|
6057
|
+
const date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
6058
|
+
return path3.resolve(process.cwd(), `canonry-report-${project}-${date}.html`);
|
|
6059
|
+
}
|
|
6060
|
+
async function runReportCommand(project, opts = {}) {
|
|
6061
|
+
const client = createApiClient();
|
|
6062
|
+
const report = await client.getReport(project);
|
|
6063
|
+
if (opts.format === "json") {
|
|
6064
|
+
console.log(JSON.stringify(report, null, 2));
|
|
6065
|
+
return;
|
|
6066
|
+
}
|
|
6067
|
+
const html = renderReportHtml(report);
|
|
6068
|
+
const targetPath = opts.output ? path3.resolve(opts.output) : defaultOutputPath(project);
|
|
6069
|
+
const dir = path3.dirname(targetPath);
|
|
6070
|
+
if (!fs4.existsSync(dir)) {
|
|
6071
|
+
fs4.mkdirSync(dir, { recursive: true });
|
|
6072
|
+
}
|
|
6073
|
+
fs4.writeFileSync(targetPath, html, "utf-8");
|
|
6074
|
+
console.log(`Report written to ${targetPath}`);
|
|
6075
|
+
}
|
|
6076
|
+
|
|
6077
|
+
// src/cli-commands/report.ts
|
|
6078
|
+
var USAGE2 = "canonry report <project> [--output <path>] [--format json]";
|
|
6079
|
+
var REPORT_CLI_COMMANDS = [
|
|
6080
|
+
{
|
|
6081
|
+
path: ["report"],
|
|
6082
|
+
usage: USAGE2,
|
|
6083
|
+
options: {
|
|
6084
|
+
output: { type: "string", short: "o" }
|
|
6085
|
+
},
|
|
6086
|
+
run: async (input) => {
|
|
6087
|
+
const project = requireProject(input, "report", USAGE2);
|
|
6088
|
+
await runReportCommand(project, {
|
|
6089
|
+
format: input.format,
|
|
6090
|
+
output: getString(input.values, "output")
|
|
6091
|
+
});
|
|
6092
|
+
}
|
|
6093
|
+
}
|
|
6094
|
+
];
|
|
6095
|
+
|
|
5177
6096
|
// src/commands/run.ts
|
|
5178
6097
|
function getClient14() {
|
|
5179
6098
|
return createApiClient();
|
|
@@ -5882,19 +6801,19 @@ Usage: canonry settings provider ${name} --api-key <key> [--model <model>] [--ma
|
|
|
5882
6801
|
];
|
|
5883
6802
|
|
|
5884
6803
|
// src/commands/skills.ts
|
|
5885
|
-
import
|
|
5886
|
-
import
|
|
6804
|
+
import fs5 from "fs";
|
|
6805
|
+
import path4 from "path";
|
|
5887
6806
|
import { fileURLToPath } from "url";
|
|
5888
6807
|
var BUNDLED_SKILL_NAMES = ["canonry-setup", "aero"];
|
|
5889
6808
|
function resolveBundledSkillsRoot(pkgDir) {
|
|
5890
|
-
const here = pkgDir ??
|
|
6809
|
+
const here = pkgDir ?? path4.dirname(fileURLToPath(import.meta.url));
|
|
5891
6810
|
const candidates = [
|
|
5892
|
-
|
|
5893
|
-
|
|
5894
|
-
|
|
6811
|
+
path4.join(here, "../assets/agent-workspace/skills"),
|
|
6812
|
+
path4.join(here, "../../assets/agent-workspace/skills"),
|
|
6813
|
+
path4.join(here, "../../../../skills")
|
|
5895
6814
|
];
|
|
5896
6815
|
for (const candidate of candidates) {
|
|
5897
|
-
if (BUNDLED_SKILL_NAMES.every((name) =>
|
|
6816
|
+
if (BUNDLED_SKILL_NAMES.every((name) => fs5.existsSync(path4.join(candidate, name, "SKILL.md")))) {
|
|
5898
6817
|
return candidate;
|
|
5899
6818
|
}
|
|
5900
6819
|
}
|
|
@@ -5915,17 +6834,17 @@ function parseDescription(content) {
|
|
|
5915
6834
|
function getBundledSkills(pkgDir) {
|
|
5916
6835
|
const root = resolveBundledSkillsRoot(pkgDir);
|
|
5917
6836
|
return BUNDLED_SKILL_NAMES.map((name) => {
|
|
5918
|
-
const skillDir =
|
|
5919
|
-
const skillFile =
|
|
5920
|
-
const content =
|
|
6837
|
+
const skillDir = path4.join(root, name);
|
|
6838
|
+
const skillFile = path4.join(skillDir, "SKILL.md");
|
|
6839
|
+
const content = fs5.readFileSync(skillFile, "utf-8");
|
|
5921
6840
|
return { name, description: parseDescription(content), bundledPath: skillDir };
|
|
5922
6841
|
});
|
|
5923
6842
|
}
|
|
5924
6843
|
function walkRelative(dir, prefix = "") {
|
|
5925
6844
|
const out = [];
|
|
5926
|
-
for (const entry of
|
|
5927
|
-
const rel = prefix ?
|
|
5928
|
-
const full =
|
|
6845
|
+
for (const entry of fs5.readdirSync(dir, { withFileTypes: true })) {
|
|
6846
|
+
const rel = prefix ? path4.join(prefix, entry.name) : entry.name;
|
|
6847
|
+
const full = path4.join(dir, entry.name);
|
|
5929
6848
|
if (entry.isDirectory()) {
|
|
5930
6849
|
out.push(...walkRelative(full, rel));
|
|
5931
6850
|
} else if (entry.isFile()) {
|
|
@@ -5935,33 +6854,33 @@ function walkRelative(dir, prefix = "") {
|
|
|
5935
6854
|
return out.sort();
|
|
5936
6855
|
}
|
|
5937
6856
|
function compareDirContent(srcDir, destDir) {
|
|
5938
|
-
if (!
|
|
5939
|
-
if (!
|
|
6857
|
+
if (!fs5.existsSync(destDir)) return "missing";
|
|
6858
|
+
if (!fs5.statSync(destDir).isDirectory()) return "different";
|
|
5940
6859
|
const srcFiles = walkRelative(srcDir);
|
|
5941
6860
|
const destFiles = walkRelative(destDir);
|
|
5942
6861
|
if (srcFiles.length !== destFiles.length) return "different";
|
|
5943
6862
|
for (let i = 0; i < srcFiles.length; i++) {
|
|
5944
6863
|
if (srcFiles[i] !== destFiles[i]) return "different";
|
|
5945
|
-
const srcBytes =
|
|
5946
|
-
const destBytes =
|
|
6864
|
+
const srcBytes = fs5.readFileSync(path4.join(srcDir, srcFiles[i]));
|
|
6865
|
+
const destBytes = fs5.readFileSync(path4.join(destDir, destFiles[i]));
|
|
5947
6866
|
if (!srcBytes.equals(destBytes)) return "different";
|
|
5948
6867
|
}
|
|
5949
6868
|
return "match";
|
|
5950
6869
|
}
|
|
5951
6870
|
function copyDirRecursive(src, dest) {
|
|
5952
|
-
|
|
5953
|
-
for (const entry of
|
|
5954
|
-
const srcPath =
|
|
5955
|
-
const destPath =
|
|
6871
|
+
fs5.mkdirSync(dest, { recursive: true });
|
|
6872
|
+
for (const entry of fs5.readdirSync(src, { withFileTypes: true })) {
|
|
6873
|
+
const srcPath = path4.join(src, entry.name);
|
|
6874
|
+
const destPath = path4.join(dest, entry.name);
|
|
5956
6875
|
if (entry.isDirectory()) {
|
|
5957
6876
|
copyDirRecursive(srcPath, destPath);
|
|
5958
6877
|
} else if (entry.isFile()) {
|
|
5959
|
-
|
|
6878
|
+
fs5.copyFileSync(srcPath, destPath);
|
|
5960
6879
|
}
|
|
5961
6880
|
}
|
|
5962
6881
|
}
|
|
5963
6882
|
function installClaudeSkill(skill, targetDir, force) {
|
|
5964
|
-
const targetPath =
|
|
6883
|
+
const targetPath = path4.join(targetDir, ".claude", "skills", skill.name);
|
|
5965
6884
|
const compare = compareDirContent(skill.bundledPath, targetPath);
|
|
5966
6885
|
if (compare === "match") {
|
|
5967
6886
|
return {
|
|
@@ -5981,7 +6900,7 @@ function installClaudeSkill(skill, targetDir, force) {
|
|
|
5981
6900
|
});
|
|
5982
6901
|
}
|
|
5983
6902
|
if (compare === "different") {
|
|
5984
|
-
|
|
6903
|
+
fs5.rmSync(targetPath, { recursive: true, force: true });
|
|
5985
6904
|
}
|
|
5986
6905
|
copyDirRecursive(skill.bundledPath, targetPath);
|
|
5987
6906
|
return {
|
|
@@ -5993,18 +6912,18 @@ function installClaudeSkill(skill, targetDir, force) {
|
|
|
5993
6912
|
};
|
|
5994
6913
|
}
|
|
5995
6914
|
function installCodexSymlink(skill, targetDir, force) {
|
|
5996
|
-
const codexPath =
|
|
5997
|
-
const claudePath =
|
|
5998
|
-
const linkTarget =
|
|
5999
|
-
|
|
6915
|
+
const codexPath = path4.join(targetDir, ".codex", "skills", skill.name);
|
|
6916
|
+
const claudePath = path4.join(targetDir, ".claude", "skills", skill.name);
|
|
6917
|
+
const linkTarget = path4.relative(path4.dirname(codexPath), claudePath);
|
|
6918
|
+
fs5.mkdirSync(path4.dirname(codexPath), { recursive: true });
|
|
6000
6919
|
let stat;
|
|
6001
6920
|
try {
|
|
6002
|
-
stat =
|
|
6921
|
+
stat = fs5.lstatSync(codexPath);
|
|
6003
6922
|
} catch {
|
|
6004
6923
|
stat = void 0;
|
|
6005
6924
|
}
|
|
6006
6925
|
if (stat?.isSymbolicLink()) {
|
|
6007
|
-
const existing =
|
|
6926
|
+
const existing = fs5.readlinkSync(codexPath);
|
|
6008
6927
|
if (existing === linkTarget) {
|
|
6009
6928
|
return {
|
|
6010
6929
|
skill: skill.name,
|
|
@@ -6022,8 +6941,8 @@ function installCodexSymlink(skill, targetDir, force) {
|
|
|
6022
6941
|
exitCode: 1
|
|
6023
6942
|
});
|
|
6024
6943
|
}
|
|
6025
|
-
|
|
6026
|
-
|
|
6944
|
+
fs5.unlinkSync(codexPath);
|
|
6945
|
+
fs5.symlinkSync(linkTarget, codexPath);
|
|
6027
6946
|
return {
|
|
6028
6947
|
skill: skill.name,
|
|
6029
6948
|
client: CodingAgents.codex,
|
|
@@ -6041,9 +6960,9 @@ function installCodexSymlink(skill, targetDir, force) {
|
|
|
6041
6960
|
exitCode: 1
|
|
6042
6961
|
});
|
|
6043
6962
|
}
|
|
6044
|
-
|
|
6963
|
+
fs5.rmSync(codexPath, { recursive: true, force: true });
|
|
6045
6964
|
}
|
|
6046
|
-
|
|
6965
|
+
fs5.symlinkSync(linkTarget, codexPath);
|
|
6047
6966
|
return {
|
|
6048
6967
|
skill: skill.name,
|
|
6049
6968
|
client: CodingAgents.codex,
|
|
@@ -6059,7 +6978,7 @@ function buildSummaryMessage(results) {
|
|
|
6059
6978
|
return `Skills install summary: ${parts.join(", ")}.`;
|
|
6060
6979
|
}
|
|
6061
6980
|
async function installSkills(opts = {}) {
|
|
6062
|
-
const targetDir =
|
|
6981
|
+
const targetDir = path4.resolve(opts.dir ?? process.cwd());
|
|
6063
6982
|
const client = opts.client ?? SkillsClients.all;
|
|
6064
6983
|
const force = opts.force ?? false;
|
|
6065
6984
|
const allSkills = getBundledSkills();
|
|
@@ -6075,7 +6994,7 @@ async function installSkills(opts = {}) {
|
|
|
6075
6994
|
});
|
|
6076
6995
|
}
|
|
6077
6996
|
const skillsToInstall = allSkills.filter((s) => requestedNames.includes(s.name));
|
|
6078
|
-
|
|
6997
|
+
fs5.mkdirSync(targetDir, { recursive: true });
|
|
6079
6998
|
const results = [];
|
|
6080
6999
|
for (const skill of skillsToInstall) {
|
|
6081
7000
|
results.push(installClaudeSkill(skill, targetDir, force));
|
|
@@ -6176,12 +7095,12 @@ var SKILLS_CLI_COMMANDS = [
|
|
|
6176
7095
|
];
|
|
6177
7096
|
|
|
6178
7097
|
// src/commands/snapshot.ts
|
|
6179
|
-
import
|
|
6180
|
-
import
|
|
7098
|
+
import fs7 from "fs";
|
|
7099
|
+
import path6 from "path";
|
|
6181
7100
|
|
|
6182
7101
|
// src/snapshot-pdf.ts
|
|
6183
|
-
import
|
|
6184
|
-
import
|
|
7102
|
+
import fs6 from "fs";
|
|
7103
|
+
import path5 from "path";
|
|
6185
7104
|
import { PDFDocument, StandardFonts, rgb } from "pdf-lib";
|
|
6186
7105
|
var PAGE_WIDTH = 612;
|
|
6187
7106
|
var PAGE_HEIGHT = 792;
|
|
@@ -6390,9 +7309,9 @@ async function writeSnapshotPdf(report, outputPath) {
|
|
|
6390
7309
|
renderCompetitors(pdf, report);
|
|
6391
7310
|
renderQueries(pdf, report);
|
|
6392
7311
|
const bytes = await doc.save();
|
|
6393
|
-
const resolvedPath =
|
|
6394
|
-
|
|
6395
|
-
|
|
7312
|
+
const resolvedPath = path5.resolve(outputPath);
|
|
7313
|
+
fs6.mkdirSync(path5.dirname(resolvedPath), { recursive: true });
|
|
7314
|
+
fs6.writeFileSync(resolvedPath, bytes);
|
|
6396
7315
|
return resolvedPath;
|
|
6397
7316
|
}
|
|
6398
7317
|
function renderCover(pdf, report) {
|
|
@@ -6550,9 +7469,9 @@ Markdown saved: ${savedMdPath}`);
|
|
|
6550
7469
|
PDF saved: ${savedPdfPath}`);
|
|
6551
7470
|
}
|
|
6552
7471
|
function writeSnapshotMarkdown(report, outputPath) {
|
|
6553
|
-
const resolvedPath =
|
|
6554
|
-
|
|
6555
|
-
|
|
7472
|
+
const resolvedPath = path6.resolve(outputPath);
|
|
7473
|
+
fs7.mkdirSync(path6.dirname(resolvedPath), { recursive: true });
|
|
7474
|
+
fs7.writeFileSync(resolvedPath, formatSnapshotMarkdown(report), "utf-8");
|
|
6556
7475
|
return resolvedPath;
|
|
6557
7476
|
}
|
|
6558
7477
|
function formatSnapshotMarkdown(report) {
|
|
@@ -7207,7 +8126,7 @@ var CONTENT_CLI_COMMANDS = [
|
|
|
7207
8126
|
|
|
7208
8127
|
// src/commands/bootstrap.ts
|
|
7209
8128
|
import crypto from "crypto";
|
|
7210
|
-
import
|
|
8129
|
+
import path7 from "path";
|
|
7211
8130
|
import { eq as eq2 } from "drizzle-orm";
|
|
7212
8131
|
|
|
7213
8132
|
// ../config/src/index.ts
|
|
@@ -7354,7 +8273,7 @@ async function bootstrapCommand(_opts) {
|
|
|
7354
8273
|
);
|
|
7355
8274
|
}
|
|
7356
8275
|
const configDir = getConfigDir();
|
|
7357
|
-
const databasePath = env.databasePath ||
|
|
8276
|
+
const databasePath = env.databasePath || path7.join(configDir, "data.db");
|
|
7358
8277
|
const existing = configExists();
|
|
7359
8278
|
const existingConfig = existing ? loadConfig() : void 0;
|
|
7360
8279
|
let rawApiKey;
|
|
@@ -7424,10 +8343,10 @@ async function bootstrapCommand(_opts) {
|
|
|
7424
8343
|
|
|
7425
8344
|
// src/commands/daemon.ts
|
|
7426
8345
|
import { spawn } from "child_process";
|
|
7427
|
-
import
|
|
7428
|
-
import
|
|
8346
|
+
import fs8 from "fs";
|
|
8347
|
+
import path8 from "path";
|
|
7429
8348
|
function getPidPath() {
|
|
7430
|
-
return
|
|
8349
|
+
return path8.join(getConfigDir(), "canonry.pid");
|
|
7431
8350
|
}
|
|
7432
8351
|
function isProcessAlive(pid) {
|
|
7433
8352
|
try {
|
|
@@ -7454,8 +8373,8 @@ async function waitForReady(host, port, maxMs = 1e4) {
|
|
|
7454
8373
|
async function startDaemon(opts) {
|
|
7455
8374
|
const pidPath = getPidPath();
|
|
7456
8375
|
const format = opts.format ?? "text";
|
|
7457
|
-
if (
|
|
7458
|
-
const existingPid = parseInt(
|
|
8376
|
+
if (fs8.existsSync(pidPath)) {
|
|
8377
|
+
const existingPid = parseInt(fs8.readFileSync(pidPath, "utf-8").trim(), 10);
|
|
7459
8378
|
if (!isNaN(existingPid) && isProcessAlive(existingPid)) {
|
|
7460
8379
|
throw new CliError({
|
|
7461
8380
|
code: "DAEMON_ALREADY_RUNNING",
|
|
@@ -7466,9 +8385,9 @@ async function startDaemon(opts) {
|
|
|
7466
8385
|
}
|
|
7467
8386
|
});
|
|
7468
8387
|
}
|
|
7469
|
-
|
|
8388
|
+
fs8.unlinkSync(pidPath);
|
|
7470
8389
|
}
|
|
7471
|
-
const cliPath =
|
|
8390
|
+
const cliPath = path8.resolve(new URL(import.meta.url).pathname);
|
|
7472
8391
|
const inSourceMode = new URL(import.meta.url).pathname.endsWith(".ts");
|
|
7473
8392
|
const args = inSourceMode ? ["--import", "tsx", cliPath, "serve"] : [cliPath, "serve"];
|
|
7474
8393
|
if (opts.port) args.push("--port", opts.port);
|
|
@@ -7487,10 +8406,10 @@ async function startDaemon(opts) {
|
|
|
7487
8406
|
});
|
|
7488
8407
|
}
|
|
7489
8408
|
const configDir = getConfigDir();
|
|
7490
|
-
if (!
|
|
7491
|
-
|
|
8409
|
+
if (!fs8.existsSync(configDir)) {
|
|
8410
|
+
fs8.mkdirSync(configDir, { recursive: true });
|
|
7492
8411
|
}
|
|
7493
|
-
|
|
8412
|
+
fs8.writeFileSync(pidPath, String(child.pid), "utf-8");
|
|
7494
8413
|
const port = opts.port ?? "4100";
|
|
7495
8414
|
const host = opts.host ?? "127.0.0.1";
|
|
7496
8415
|
if (format !== "json") {
|
|
@@ -7499,7 +8418,7 @@ async function startDaemon(opts) {
|
|
|
7499
8418
|
const ready = await waitForReady(host, port);
|
|
7500
8419
|
if (!ready) {
|
|
7501
8420
|
try {
|
|
7502
|
-
|
|
8421
|
+
fs8.unlinkSync(pidPath);
|
|
7503
8422
|
} catch {
|
|
7504
8423
|
}
|
|
7505
8424
|
throw new CliError({
|
|
@@ -7531,7 +8450,7 @@ async function startDaemon(opts) {
|
|
|
7531
8450
|
}
|
|
7532
8451
|
function stopDaemon(format = "text") {
|
|
7533
8452
|
const pidPath = getPidPath();
|
|
7534
|
-
if (!
|
|
8453
|
+
if (!fs8.existsSync(pidPath)) {
|
|
7535
8454
|
if (format === "json") {
|
|
7536
8455
|
console.log(JSON.stringify({
|
|
7537
8456
|
stopped: false,
|
|
@@ -7542,7 +8461,7 @@ function stopDaemon(format = "text") {
|
|
|
7542
8461
|
console.log("Canonry is not running (no PID file found)");
|
|
7543
8462
|
return;
|
|
7544
8463
|
}
|
|
7545
|
-
const pid = parseInt(
|
|
8464
|
+
const pid = parseInt(fs8.readFileSync(pidPath, "utf-8").trim(), 10);
|
|
7546
8465
|
if (isNaN(pid)) {
|
|
7547
8466
|
if (format === "json") {
|
|
7548
8467
|
console.log(JSON.stringify({
|
|
@@ -7553,7 +8472,7 @@ function stopDaemon(format = "text") {
|
|
|
7553
8472
|
} else {
|
|
7554
8473
|
console.error("Invalid PID file. Removing it.");
|
|
7555
8474
|
}
|
|
7556
|
-
|
|
8475
|
+
fs8.unlinkSync(pidPath);
|
|
7557
8476
|
return;
|
|
7558
8477
|
}
|
|
7559
8478
|
if (!isProcessAlive(pid)) {
|
|
@@ -7567,12 +8486,12 @@ function stopDaemon(format = "text") {
|
|
|
7567
8486
|
} else {
|
|
7568
8487
|
console.log(`Canonry is not running (stale PID: ${pid}). Cleaning up.`);
|
|
7569
8488
|
}
|
|
7570
|
-
|
|
8489
|
+
fs8.unlinkSync(pidPath);
|
|
7571
8490
|
return;
|
|
7572
8491
|
}
|
|
7573
8492
|
try {
|
|
7574
8493
|
process.kill(pid, "SIGTERM");
|
|
7575
|
-
|
|
8494
|
+
fs8.unlinkSync(pidPath);
|
|
7576
8495
|
if (format === "json") {
|
|
7577
8496
|
console.log(JSON.stringify({
|
|
7578
8497
|
stopped: true,
|
|
@@ -7596,9 +8515,9 @@ function stopDaemon(format = "text") {
|
|
|
7596
8515
|
|
|
7597
8516
|
// src/commands/init.ts
|
|
7598
8517
|
import crypto2 from "crypto";
|
|
7599
|
-
import
|
|
8518
|
+
import fs9 from "fs";
|
|
7600
8519
|
import readline from "readline";
|
|
7601
|
-
import
|
|
8520
|
+
import path9 from "path";
|
|
7602
8521
|
function prompt(question) {
|
|
7603
8522
|
const rl = readline.createInterface({
|
|
7604
8523
|
input: process.stdin,
|
|
@@ -7619,8 +8538,8 @@ var DEFAULT_QUOTA = {
|
|
|
7619
8538
|
var PROJECT_MARKERS = [".git", "canonry.yaml", "canonry.yml", "package.json"];
|
|
7620
8539
|
function cwdLooksLikeProject(dir) {
|
|
7621
8540
|
const home = process.env.HOME ?? "";
|
|
7622
|
-
if (home &&
|
|
7623
|
-
return PROJECT_MARKERS.some((marker) =>
|
|
8541
|
+
if (home && path9.resolve(dir) === path9.resolve(home)) return false;
|
|
8542
|
+
return PROJECT_MARKERS.some((marker) => fs9.existsSync(path9.join(dir, marker)));
|
|
7624
8543
|
}
|
|
7625
8544
|
var DEFAULT_AGENT_MODELS = {
|
|
7626
8545
|
anthropic: "anthropic/claude-sonnet-4-6",
|
|
@@ -7650,8 +8569,8 @@ async function initCommand(opts) {
|
|
|
7650
8569
|
return void 0;
|
|
7651
8570
|
}
|
|
7652
8571
|
const configDir = getConfigDir();
|
|
7653
|
-
if (!
|
|
7654
|
-
|
|
8572
|
+
if (!fs9.existsSync(configDir)) {
|
|
8573
|
+
fs9.mkdirSync(configDir, { recursive: true });
|
|
7655
8574
|
}
|
|
7656
8575
|
const bootstrapEnv = getBootstrapEnv(process.env, {
|
|
7657
8576
|
GEMINI_API_KEY: opts?.geminiKey,
|
|
@@ -7766,7 +8685,7 @@ async function initCommand(opts) {
|
|
|
7766
8685
|
const rawApiKey = `cnry_${crypto2.randomBytes(16).toString("hex")}`;
|
|
7767
8686
|
const keyHash = crypto2.createHash("sha256").update(rawApiKey).digest("hex");
|
|
7768
8687
|
const keyPrefix = rawApiKey.slice(0, 9);
|
|
7769
|
-
const databasePath =
|
|
8688
|
+
const databasePath = path9.join(configDir, "data.db");
|
|
7770
8689
|
const db = createClient(databasePath);
|
|
7771
8690
|
migrate(db);
|
|
7772
8691
|
db.insert(apiKeys).values({
|
|
@@ -8214,7 +9133,7 @@ var SYSTEM_CLI_COMMANDS = [
|
|
|
8214
9133
|
];
|
|
8215
9134
|
|
|
8216
9135
|
// src/cli-commands/wordpress.ts
|
|
8217
|
-
import
|
|
9136
|
+
import fs10 from "fs";
|
|
8218
9137
|
|
|
8219
9138
|
// src/commands/wordpress.ts
|
|
8220
9139
|
function getClient18() {
|
|
@@ -8450,12 +9369,12 @@ async function wordpressSetMeta(project, body) {
|
|
|
8450
9369
|
printPageDetail(result);
|
|
8451
9370
|
}
|
|
8452
9371
|
async function wordpressBulkSetMeta(project, opts) {
|
|
8453
|
-
const
|
|
8454
|
-
const
|
|
8455
|
-
const filePath =
|
|
9372
|
+
const fs11 = await import("fs/promises");
|
|
9373
|
+
const path10 = await import("path");
|
|
9374
|
+
const filePath = path10.resolve(opts.from);
|
|
8456
9375
|
let raw;
|
|
8457
9376
|
try {
|
|
8458
|
-
raw = await
|
|
9377
|
+
raw = await fs11.readFile(filePath, "utf8");
|
|
8459
9378
|
} catch {
|
|
8460
9379
|
throw new CliError({
|
|
8461
9380
|
code: "FILE_READ_ERROR",
|
|
@@ -8552,13 +9471,13 @@ async function wordpressSetSchema(project, body) {
|
|
|
8552
9471
|
printManualAssist(`Schema update for "${body.slug}"`, result);
|
|
8553
9472
|
}
|
|
8554
9473
|
async function wordpressSchemaDeploy(project, opts) {
|
|
8555
|
-
const
|
|
8556
|
-
const
|
|
9474
|
+
const fs11 = await import("fs/promises");
|
|
9475
|
+
const path10 = await import("path");
|
|
8557
9476
|
const yaml = await import("yaml").catch(() => null);
|
|
8558
|
-
const filePath =
|
|
9477
|
+
const filePath = path10.resolve(opts.profile);
|
|
8559
9478
|
let raw;
|
|
8560
9479
|
try {
|
|
8561
|
-
raw = await
|
|
9480
|
+
raw = await fs11.readFile(filePath, "utf8");
|
|
8562
9481
|
} catch {
|
|
8563
9482
|
throw new CliError({
|
|
8564
9483
|
code: "FILE_READ_ERROR",
|
|
@@ -8663,13 +9582,13 @@ async function wordpressOnboard(project, opts) {
|
|
|
8663
9582
|
}
|
|
8664
9583
|
let profileData;
|
|
8665
9584
|
if (opts.profile) {
|
|
8666
|
-
const
|
|
8667
|
-
const
|
|
9585
|
+
const fs11 = await import("fs/promises");
|
|
9586
|
+
const path10 = await import("path");
|
|
8668
9587
|
const yaml = await import("yaml").catch(() => null);
|
|
8669
|
-
const filePath =
|
|
9588
|
+
const filePath = path10.resolve(opts.profile);
|
|
8670
9589
|
let raw;
|
|
8671
9590
|
try {
|
|
8672
|
-
raw = await
|
|
9591
|
+
raw = await fs11.readFile(filePath, "utf8");
|
|
8673
9592
|
} catch {
|
|
8674
9593
|
throw new CliError({
|
|
8675
9594
|
code: "FILE_READ_ERROR",
|
|
@@ -8818,7 +9737,7 @@ function resolveContent(input, command, usage, options) {
|
|
|
8818
9737
|
}
|
|
8819
9738
|
if (contentFile) {
|
|
8820
9739
|
try {
|
|
8821
|
-
return
|
|
9740
|
+
return fs10.readFileSync(contentFile, "utf-8");
|
|
8822
9741
|
} catch (error) {
|
|
8823
9742
|
const message = error instanceof Error ? error.message : String(error);
|
|
8824
9743
|
throw usageError(`Error: could not read --content-file "${contentFile}": ${message}`, {
|
|
@@ -9749,6 +10668,7 @@ var REGISTERED_CLI_COMMANDS = [
|
|
|
9749
10668
|
...BACKLINKS_CLI_COMMANDS,
|
|
9750
10669
|
...SYSTEM_CLI_COMMANDS,
|
|
9751
10670
|
...PROJECT_CLI_COMMANDS,
|
|
10671
|
+
...REPORT_CLI_COMMANDS,
|
|
9752
10672
|
...KEYWORD_CLI_COMMANDS,
|
|
9753
10673
|
...COMPETITOR_CLI_COMMANDS,
|
|
9754
10674
|
...SETTINGS_CLI_COMMANDS,
|
|
@@ -9772,7 +10692,7 @@ var REGISTERED_CLI_COMMANDS = [
|
|
|
9772
10692
|
|
|
9773
10693
|
// src/cli.ts
|
|
9774
10694
|
import { createRequire as createRequire2 } from "module";
|
|
9775
|
-
var
|
|
10695
|
+
var USAGE3 = `
|
|
9776
10696
|
canonry \u2014 AEO monitoring CLI
|
|
9777
10697
|
|
|
9778
10698
|
Usage: canonry <command> [options]
|
|
@@ -9833,7 +10753,7 @@ function extractFormat(cmdArgs) {
|
|
|
9833
10753
|
}
|
|
9834
10754
|
async function runCli(args = process.argv.slice(2)) {
|
|
9835
10755
|
if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
|
|
9836
|
-
console.log(
|
|
10756
|
+
console.log(USAGE3);
|
|
9837
10757
|
return 0;
|
|
9838
10758
|
}
|
|
9839
10759
|
if (args.includes("--version") || args.includes("-v")) {
|