@ainyc/canonry 3.3.3 → 3.3.9
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-NCGX6UI4.js → chunk-24C7RMIS.js} +14 -0
- package/dist/{chunk-GH5ILITH.js → chunk-P6D3O5JB.js} +898 -248
- package/dist/cli.js +1077 -94
- package/dist/index.js +2 -2
- package/dist/mcp.js +1 -1
- package/package.json +5 -5
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,988 @@ 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 summarizeQueryParams(params) {
|
|
5210
|
+
const keys = Array.from(params.keys());
|
|
5211
|
+
const total = keys.length;
|
|
5212
|
+
if (total === 0) return "";
|
|
5213
|
+
const noun = total === 1 ? "param" : "params";
|
|
5214
|
+
const tag = inferAdSource(params);
|
|
5215
|
+
return tag ? `${tag} \xB7 ${total} ${noun}` : `${total} tracking ${noun}`;
|
|
5216
|
+
}
|
|
5217
|
+
function inferAdSource(params) {
|
|
5218
|
+
if (params.has("fbclid")) return "Facebook Ad";
|
|
5219
|
+
if (params.has("gclid") || params.has("gbraid") || params.has("wbraid")) return "Google Ad";
|
|
5220
|
+
if (params.has("msclkid")) return "Microsoft Ad";
|
|
5221
|
+
if (params.has("ttclid")) return "TikTok Ad";
|
|
5222
|
+
if (params.has("li_fat_id")) return "LinkedIn Ad";
|
|
5223
|
+
if (params.has("twclid")) return "X / Twitter Ad";
|
|
5224
|
+
if (params.has("epik")) return "Pinterest Ad";
|
|
5225
|
+
for (const k of params.keys()) {
|
|
5226
|
+
if (k.startsWith("hsa_")) return "Search Ad";
|
|
5227
|
+
}
|
|
5228
|
+
const src = params.get("utm_source");
|
|
5229
|
+
const med = params.get("utm_medium");
|
|
5230
|
+
if (src && med) return `${src} / ${med}`;
|
|
5231
|
+
if (src) return `Source: ${src}`;
|
|
5232
|
+
if (med) return `Medium: ${med}`;
|
|
5233
|
+
return null;
|
|
5234
|
+
}
|
|
5235
|
+
function formatLandingPageHtml(raw) {
|
|
5236
|
+
const value = raw ?? "";
|
|
5237
|
+
const queryIdx = value.indexOf("?");
|
|
5238
|
+
const path10 = queryIdx === -1 ? value : value.slice(0, queryIdx);
|
|
5239
|
+
const query = queryIdx === -1 ? "" : value.slice(queryIdx + 1);
|
|
5240
|
+
const pathHtml = `<span class="page-path">${escapeHtml(path10 || "/")}</span>`;
|
|
5241
|
+
if (!query) return pathHtml;
|
|
5242
|
+
let summary = "";
|
|
5243
|
+
try {
|
|
5244
|
+
summary = summarizeQueryParams(new URLSearchParams(query));
|
|
5245
|
+
} catch {
|
|
5246
|
+
summary = "tracking params";
|
|
5247
|
+
}
|
|
5248
|
+
if (!summary) return pathHtml;
|
|
5249
|
+
return `${pathHtml}<span class="page-query" title="${escapeHtml(value)}">${escapeHtml(summary)}</span>`;
|
|
5250
|
+
}
|
|
5251
|
+
function formatDate(iso) {
|
|
5252
|
+
if (!iso) return "\u2014";
|
|
5253
|
+
try {
|
|
5254
|
+
const d = new Date(iso);
|
|
5255
|
+
return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
|
5256
|
+
} catch {
|
|
5257
|
+
return iso;
|
|
5258
|
+
}
|
|
5259
|
+
}
|
|
5260
|
+
function pressureTone(label) {
|
|
5261
|
+
if (label === "High") return "negative";
|
|
5262
|
+
if (label === "Moderate") return "caution";
|
|
5263
|
+
if (label === "Low") return "positive";
|
|
5264
|
+
return "neutral";
|
|
5265
|
+
}
|
|
5266
|
+
function severityTone(severity) {
|
|
5267
|
+
switch (severity) {
|
|
5268
|
+
case "critical":
|
|
5269
|
+
return "negative";
|
|
5270
|
+
case "high":
|
|
5271
|
+
return "negative";
|
|
5272
|
+
case "medium":
|
|
5273
|
+
return "caution";
|
|
5274
|
+
case "low":
|
|
5275
|
+
return "neutral";
|
|
5276
|
+
}
|
|
5277
|
+
}
|
|
5278
|
+
var STYLE = `
|
|
5279
|
+
:root {
|
|
5280
|
+
color-scheme: dark;
|
|
5281
|
+
}
|
|
5282
|
+
* { box-sizing: border-box; }
|
|
5283
|
+
html, body { margin: 0; padding: 0; }
|
|
5284
|
+
body {
|
|
5285
|
+
background: ${COLORS.bg};
|
|
5286
|
+
color: ${COLORS.text};
|
|
5287
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
|
5288
|
+
font-size: 14px;
|
|
5289
|
+
line-height: 1.5;
|
|
5290
|
+
-webkit-font-smoothing: antialiased;
|
|
5291
|
+
}
|
|
5292
|
+
.container {
|
|
5293
|
+
max-width: 1100px;
|
|
5294
|
+
margin: 0 auto;
|
|
5295
|
+
padding: 48px 24px 96px;
|
|
5296
|
+
}
|
|
5297
|
+
.header {
|
|
5298
|
+
border-bottom: 1px solid ${COLORS.border};
|
|
5299
|
+
padding-bottom: 32px;
|
|
5300
|
+
margin-bottom: 48px;
|
|
5301
|
+
}
|
|
5302
|
+
.header h1 {
|
|
5303
|
+
font-size: 32px;
|
|
5304
|
+
font-weight: 700;
|
|
5305
|
+
margin: 0 0 8px;
|
|
5306
|
+
letter-spacing: -0.02em;
|
|
5307
|
+
}
|
|
5308
|
+
.header .subtitle {
|
|
5309
|
+
color: ${COLORS.textMuted};
|
|
5310
|
+
font-size: 14px;
|
|
5311
|
+
}
|
|
5312
|
+
.eyebrow {
|
|
5313
|
+
text-transform: uppercase;
|
|
5314
|
+
letter-spacing: 0.08em;
|
|
5315
|
+
font-size: 10px;
|
|
5316
|
+
color: ${COLORS.textFaint};
|
|
5317
|
+
font-weight: 600;
|
|
5318
|
+
margin-bottom: 8px;
|
|
5319
|
+
}
|
|
5320
|
+
section.report-section {
|
|
5321
|
+
margin: 64px 0;
|
|
5322
|
+
}
|
|
5323
|
+
section.report-section h2 {
|
|
5324
|
+
font-size: 22px;
|
|
5325
|
+
font-weight: 700;
|
|
5326
|
+
margin: 0 0 24px;
|
|
5327
|
+
letter-spacing: -0.01em;
|
|
5328
|
+
}
|
|
5329
|
+
section.report-section .section-intro {
|
|
5330
|
+
color: ${COLORS.textMuted};
|
|
5331
|
+
margin-bottom: 24px;
|
|
5332
|
+
}
|
|
5333
|
+
.metric-grid {
|
|
5334
|
+
display: grid;
|
|
5335
|
+
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
|
5336
|
+
gap: 16px;
|
|
5337
|
+
}
|
|
5338
|
+
.metric {
|
|
5339
|
+
background: ${COLORS.surface};
|
|
5340
|
+
border: 1px solid ${COLORS.border};
|
|
5341
|
+
border-radius: 8px;
|
|
5342
|
+
padding: 16px 20px;
|
|
5343
|
+
}
|
|
5344
|
+
.metric .label {
|
|
5345
|
+
text-transform: uppercase;
|
|
5346
|
+
letter-spacing: 0.08em;
|
|
5347
|
+
font-size: 10px;
|
|
5348
|
+
color: ${COLORS.textFaint};
|
|
5349
|
+
font-weight: 600;
|
|
5350
|
+
margin-bottom: 8px;
|
|
5351
|
+
}
|
|
5352
|
+
.metric .value {
|
|
5353
|
+
font-size: 28px;
|
|
5354
|
+
font-weight: 700;
|
|
5355
|
+
letter-spacing: -0.02em;
|
|
5356
|
+
}
|
|
5357
|
+
.metric .delta {
|
|
5358
|
+
font-size: 12px;
|
|
5359
|
+
color: ${COLORS.textMuted};
|
|
5360
|
+
margin-top: 4px;
|
|
5361
|
+
}
|
|
5362
|
+
.findings {
|
|
5363
|
+
margin-top: 24px;
|
|
5364
|
+
display: grid;
|
|
5365
|
+
gap: 12px;
|
|
5366
|
+
}
|
|
5367
|
+
.finding {
|
|
5368
|
+
background: ${COLORS.surface};
|
|
5369
|
+
border: 1px solid ${COLORS.border};
|
|
5370
|
+
border-left-width: 3px;
|
|
5371
|
+
border-radius: 6px;
|
|
5372
|
+
padding: 12px 16px;
|
|
5373
|
+
}
|
|
5374
|
+
.finding.tone-positive { border-left-color: ${COLORS.positive}; }
|
|
5375
|
+
.finding.tone-caution { border-left-color: ${COLORS.caution}; }
|
|
5376
|
+
.finding.tone-negative { border-left-color: ${COLORS.negative}; }
|
|
5377
|
+
.finding.tone-neutral { border-left-color: ${COLORS.neutral}; }
|
|
5378
|
+
.finding strong { display: block; margin-bottom: 4px; }
|
|
5379
|
+
.finding span { color: ${COLORS.textMuted}; font-size: 13px; }
|
|
5380
|
+
table.report-table {
|
|
5381
|
+
width: 100%;
|
|
5382
|
+
border-collapse: collapse;
|
|
5383
|
+
font-size: 13px;
|
|
5384
|
+
}
|
|
5385
|
+
table.report-table th, table.report-table td {
|
|
5386
|
+
text-align: left;
|
|
5387
|
+
padding: 10px 12px;
|
|
5388
|
+
border-bottom: 1px solid ${COLORS.border};
|
|
5389
|
+
vertical-align: top;
|
|
5390
|
+
overflow-wrap: anywhere;
|
|
5391
|
+
word-break: break-word;
|
|
5392
|
+
}
|
|
5393
|
+
table.report-table th {
|
|
5394
|
+
font-weight: 600;
|
|
5395
|
+
color: ${COLORS.textMuted};
|
|
5396
|
+
text-transform: uppercase;
|
|
5397
|
+
letter-spacing: 0.06em;
|
|
5398
|
+
font-size: 10px;
|
|
5399
|
+
}
|
|
5400
|
+
table.report-table td.numeric { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|
5401
|
+
table.report-table td.page-cell { max-width: 0; }
|
|
5402
|
+
table.report-table td.page-cell .page-path {
|
|
5403
|
+
display: block;
|
|
5404
|
+
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
|
|
5405
|
+
font-size: 12px;
|
|
5406
|
+
color: ${COLORS.text};
|
|
5407
|
+
}
|
|
5408
|
+
table.report-table td.page-cell .page-query {
|
|
5409
|
+
display: inline-block;
|
|
5410
|
+
margin-top: 4px;
|
|
5411
|
+
padding: 1px 8px;
|
|
5412
|
+
font-size: 11px;
|
|
5413
|
+
color: ${COLORS.textMuted};
|
|
5414
|
+
background: ${COLORS.surface};
|
|
5415
|
+
border: 1px solid ${COLORS.border};
|
|
5416
|
+
border-radius: 999px;
|
|
5417
|
+
cursor: help;
|
|
5418
|
+
}
|
|
5419
|
+
table.report-table td .badge {
|
|
5420
|
+
display: inline-block;
|
|
5421
|
+
padding: 2px 8px;
|
|
5422
|
+
border-radius: 999px;
|
|
5423
|
+
font-size: 11px;
|
|
5424
|
+
font-weight: 600;
|
|
5425
|
+
border: 1px solid;
|
|
5426
|
+
}
|
|
5427
|
+
.cell-cited { color: ${COLORS.positive}; font-weight: 600; }
|
|
5428
|
+
.cell-not-cited { color: ${COLORS.textFaint}; }
|
|
5429
|
+
.cell-pending { color: ${COLORS.textFaint}; font-style: italic; }
|
|
5430
|
+
.tone-positive { color: ${COLORS.positive}; }
|
|
5431
|
+
.tone-caution { color: ${COLORS.caution}; }
|
|
5432
|
+
.tone-negative { color: ${COLORS.negative}; }
|
|
5433
|
+
.tone-neutral { color: ${COLORS.neutral}; }
|
|
5434
|
+
.badge.tone-positive { color: ${COLORS.positive}; border-color: ${COLORS.positive}40; background: ${COLORS.positive}14; }
|
|
5435
|
+
.badge.tone-caution { color: ${COLORS.caution}; border-color: ${COLORS.caution}40; background: ${COLORS.caution}14; }
|
|
5436
|
+
.badge.tone-negative { color: ${COLORS.negative}; border-color: ${COLORS.negative}40; background: ${COLORS.negative}14; }
|
|
5437
|
+
.badge.tone-neutral { color: ${COLORS.textMuted}; border-color: ${COLORS.border}; background: transparent; }
|
|
5438
|
+
.chart-card {
|
|
5439
|
+
background: ${COLORS.surface};
|
|
5440
|
+
border: 1px solid ${COLORS.border};
|
|
5441
|
+
border-radius: 8px;
|
|
5442
|
+
padding: 20px;
|
|
5443
|
+
margin-bottom: 16px;
|
|
5444
|
+
}
|
|
5445
|
+
.chart-card h3 {
|
|
5446
|
+
font-size: 14px;
|
|
5447
|
+
font-weight: 600;
|
|
5448
|
+
margin: 0 0 16px;
|
|
5449
|
+
}
|
|
5450
|
+
.chart-grid {
|
|
5451
|
+
display: grid;
|
|
5452
|
+
grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
|
|
5453
|
+
gap: 16px;
|
|
5454
|
+
}
|
|
5455
|
+
.legend {
|
|
5456
|
+
display: flex;
|
|
5457
|
+
flex-wrap: wrap;
|
|
5458
|
+
gap: 12px;
|
|
5459
|
+
font-size: 12px;
|
|
5460
|
+
margin-top: 12px;
|
|
5461
|
+
}
|
|
5462
|
+
.legend-swatch {
|
|
5463
|
+
display: inline-block;
|
|
5464
|
+
width: 10px;
|
|
5465
|
+
height: 10px;
|
|
5466
|
+
border-radius: 2px;
|
|
5467
|
+
margin-right: 6px;
|
|
5468
|
+
vertical-align: middle;
|
|
5469
|
+
}
|
|
5470
|
+
.empty-state {
|
|
5471
|
+
background: ${COLORS.surface};
|
|
5472
|
+
border: 1px dashed ${COLORS.border};
|
|
5473
|
+
border-radius: 8px;
|
|
5474
|
+
padding: 32px;
|
|
5475
|
+
color: ${COLORS.textMuted};
|
|
5476
|
+
text-align: center;
|
|
5477
|
+
font-size: 13px;
|
|
5478
|
+
}
|
|
5479
|
+
.steps {
|
|
5480
|
+
display: grid;
|
|
5481
|
+
gap: 12px;
|
|
5482
|
+
}
|
|
5483
|
+
.step {
|
|
5484
|
+
background: ${COLORS.surface};
|
|
5485
|
+
border: 1px solid ${COLORS.border};
|
|
5486
|
+
border-radius: 8px;
|
|
5487
|
+
padding: 16px 20px;
|
|
5488
|
+
display: grid;
|
|
5489
|
+
gap: 4px;
|
|
5490
|
+
}
|
|
5491
|
+
.step .horizon {
|
|
5492
|
+
text-transform: uppercase;
|
|
5493
|
+
font-size: 10px;
|
|
5494
|
+
letter-spacing: 0.08em;
|
|
5495
|
+
color: ${COLORS.textFaint};
|
|
5496
|
+
font-weight: 600;
|
|
5497
|
+
}
|
|
5498
|
+
.step .title { font-weight: 600; }
|
|
5499
|
+
.step .rationale { color: ${COLORS.textMuted}; font-size: 13px; }
|
|
5500
|
+
.footer {
|
|
5501
|
+
margin-top: 96px;
|
|
5502
|
+
padding-top: 24px;
|
|
5503
|
+
border-top: 1px solid ${COLORS.border};
|
|
5504
|
+
text-align: center;
|
|
5505
|
+
color: ${COLORS.textFaint};
|
|
5506
|
+
font-size: 12px;
|
|
5507
|
+
}
|
|
5508
|
+
@media print {
|
|
5509
|
+
body { background: white; color: black; }
|
|
5510
|
+
section.report-section { break-inside: avoid; }
|
|
5511
|
+
}
|
|
5512
|
+
`;
|
|
5513
|
+
function section(opts, body) {
|
|
5514
|
+
return `<section class="report-section" id="${escapeHtml(opts.id)}">
|
|
5515
|
+
<div class="eyebrow">${escapeHtml(opts.eyebrow)}</div>
|
|
5516
|
+
<h2>${escapeHtml(opts.title)}</h2>
|
|
5517
|
+
${opts.intro ? `<p class="section-intro">${escapeHtml(opts.intro)}</p>` : ""}
|
|
5518
|
+
${body}
|
|
5519
|
+
</section>`;
|
|
5520
|
+
}
|
|
5521
|
+
function renderEmpty(message) {
|
|
5522
|
+
return `<div class="empty-state">${escapeHtml(message)}</div>`;
|
|
5523
|
+
}
|
|
5524
|
+
function renderExecutiveSummary(report) {
|
|
5525
|
+
const s = report.executiveSummary;
|
|
5526
|
+
const trendLabel = s.trend === "up" ? "\u2191 Up" : s.trend === "down" ? "\u2193 Down" : s.trend === "flat" ? "\u2192 Flat" : "\u2014";
|
|
5527
|
+
const trendTone = s.trend === "up" ? "positive" : s.trend === "down" ? "negative" : "neutral";
|
|
5528
|
+
const metrics = [
|
|
5529
|
+
{
|
|
5530
|
+
label: "Citation rate",
|
|
5531
|
+
value: `${s.citationRate}%`,
|
|
5532
|
+
delta: `<span class="tone-${trendTone}">${trendLabel}</span> \xB7 ${s.providerCount} provider${s.providerCount === 1 ? "" : "s"}`
|
|
5533
|
+
},
|
|
5534
|
+
{
|
|
5535
|
+
label: "Keywords tracked",
|
|
5536
|
+
value: formatNumber(s.keywordCount),
|
|
5537
|
+
delta: `${s.competitorCount} competitor${s.competitorCount === 1 ? "" : "s"} tracked`
|
|
5538
|
+
}
|
|
5539
|
+
];
|
|
5540
|
+
if (s.gsc) {
|
|
5541
|
+
metrics.push({
|
|
5542
|
+
label: "GSC clicks",
|
|
5543
|
+
value: formatNumber(s.gsc.clicks),
|
|
5544
|
+
delta: `${formatNumber(s.gsc.impressions)} imp \xB7 ${formatRatio(s.gsc.ctr)} CTR`
|
|
5545
|
+
});
|
|
5546
|
+
}
|
|
5547
|
+
if (s.ga) {
|
|
5548
|
+
metrics.push({
|
|
5549
|
+
label: "GA sessions",
|
|
5550
|
+
value: formatNumber(s.ga.sessions),
|
|
5551
|
+
delta: `${formatNumber(s.ga.users)} users \xB7 ${formatDate(s.ga.periodStart)} \u2192 ${formatDate(s.ga.periodEnd)}`
|
|
5552
|
+
});
|
|
5553
|
+
}
|
|
5554
|
+
const metricsHtml = `<div class="metric-grid">
|
|
5555
|
+
${metrics.map((m) => `<div class="metric">
|
|
5556
|
+
<div class="label">${escapeHtml(m.label)}</div>
|
|
5557
|
+
<div class="value">${m.value}</div>
|
|
5558
|
+
<div class="delta">${m.delta}</div>
|
|
5559
|
+
</div>`).join("")}
|
|
5560
|
+
</div>`;
|
|
5561
|
+
const findingsHtml = s.findings.length > 0 ? `<div class="findings">${s.findings.map((f) => `
|
|
5562
|
+
<div class="finding tone-${f.tone}">
|
|
5563
|
+
<strong>${escapeHtml(f.title)}</strong>
|
|
5564
|
+
<span>${escapeHtml(f.detail)}</span>
|
|
5565
|
+
</div>`).join("")}</div>` : "";
|
|
5566
|
+
return section(
|
|
5567
|
+
{ id: "executive-summary", eyebrow: "Section 1", title: "Executive Summary" },
|
|
5568
|
+
metricsHtml + findingsHtml
|
|
5569
|
+
);
|
|
5570
|
+
}
|
|
5571
|
+
function renderProviderBars(rates) {
|
|
5572
|
+
if (rates.length === 0) return "";
|
|
5573
|
+
const max = Math.max(...rates.map((r) => r.citationRate), 100);
|
|
5574
|
+
const width = 600;
|
|
5575
|
+
const height = Math.max(rates.length * 32 + 24, 80);
|
|
5576
|
+
const labelWidth = 80;
|
|
5577
|
+
const padding = 8;
|
|
5578
|
+
const barWidth = width - labelWidth - padding * 2;
|
|
5579
|
+
const bars = rates.map((r, i) => {
|
|
5580
|
+
const y = i * 32 + padding;
|
|
5581
|
+
const barHeight = 22;
|
|
5582
|
+
const w = max > 0 ? r.citationRate / max * barWidth : 0;
|
|
5583
|
+
const color = COLORS.series[i % COLORS.series.length];
|
|
5584
|
+
return `
|
|
5585
|
+
<text x="${labelWidth - 8}" y="${y + 16}" fill="${COLORS.textMuted}" font-size="11" text-anchor="end">${escapeHtml(r.provider)}</text>
|
|
5586
|
+
<rect x="${labelWidth}" y="${y}" width="${barWidth}" height="${barHeight}" fill="${COLORS.border}" opacity="0.4" rx="3" />
|
|
5587
|
+
<rect x="${labelWidth}" y="${y}" width="${w}" height="${barHeight}" fill="${color}" rx="3" />
|
|
5588
|
+
<text x="${labelWidth + w + 6}" y="${y + 16}" fill="${COLORS.text}" font-size="11">${r.citationRate}% (${r.citedCount}/${r.totalCount})</text>`;
|
|
5589
|
+
}).join("");
|
|
5590
|
+
return `<div class="chart-card">
|
|
5591
|
+
<h3>Provider citation rate</h3>
|
|
5592
|
+
<svg viewBox="0 0 ${width} ${height}" width="100%" preserveAspectRatio="xMinYMin meet" role="img" aria-label="Provider citation rate bar chart">
|
|
5593
|
+
${bars}
|
|
5594
|
+
</svg>
|
|
5595
|
+
</div>`;
|
|
5596
|
+
}
|
|
5597
|
+
function renderCitationMatrix(scorecard) {
|
|
5598
|
+
if (scorecard.keywords.length === 0 || scorecard.providers.length === 0) {
|
|
5599
|
+
return renderEmpty("Run a visibility sweep to populate the citation matrix.");
|
|
5600
|
+
}
|
|
5601
|
+
const headers = scorecard.providers.map((p) => `<th>${escapeHtml(p)}</th>`).join("");
|
|
5602
|
+
const rows = scorecard.keywords.map((kw, ki) => {
|
|
5603
|
+
const cells = scorecard.providers.map((_, pi) => {
|
|
5604
|
+
const cell = scorecard.matrix[ki]?.[pi];
|
|
5605
|
+
if (!cell) {
|
|
5606
|
+
return '<td><span class="cell-pending">\u2014</span></td>';
|
|
5607
|
+
}
|
|
5608
|
+
if (cell.citationState === "cited") {
|
|
5609
|
+
return '<td><span class="cell-cited">Cited</span></td>';
|
|
5610
|
+
}
|
|
5611
|
+
return '<td><span class="cell-not-cited">Not cited</span></td>';
|
|
5612
|
+
}).join("");
|
|
5613
|
+
return `<tr><td>${escapeHtml(kw)}</td>${cells}</tr>`;
|
|
5614
|
+
}).join("");
|
|
5615
|
+
return `<table class="report-table">
|
|
5616
|
+
<thead><tr><th>Keyword</th>${headers}</tr></thead>
|
|
5617
|
+
<tbody>${rows}</tbody>
|
|
5618
|
+
</table>`;
|
|
5619
|
+
}
|
|
5620
|
+
function renderCitationScorecard(report) {
|
|
5621
|
+
const body = `
|
|
5622
|
+
${renderProviderBars(report.citationScorecard.providerRates)}
|
|
5623
|
+
${renderCitationMatrix(report.citationScorecard)}
|
|
5624
|
+
`;
|
|
5625
|
+
return section(
|
|
5626
|
+
{ id: "citation-scorecard", eyebrow: "Section 2", title: "Citation Scorecard", intro: "Per-keyword \xD7 per-provider citation matrix from the latest visibility sweep." },
|
|
5627
|
+
body
|
|
5628
|
+
);
|
|
5629
|
+
}
|
|
5630
|
+
function renderCompetitorBars(landscape, canonical) {
|
|
5631
|
+
const data = [
|
|
5632
|
+
{ label: canonical, count: landscape.projectCitationCount, isProject: true },
|
|
5633
|
+
...landscape.competitors.map((c) => ({ label: c.domain, count: c.citationCount, isProject: false }))
|
|
5634
|
+
];
|
|
5635
|
+
if (data.length <= 1) return "";
|
|
5636
|
+
const max = Math.max(...data.map((d) => d.count), 1);
|
|
5637
|
+
const width = 600;
|
|
5638
|
+
const height = data.length * 28 + 16;
|
|
5639
|
+
const labelWidth = 160;
|
|
5640
|
+
const bars = data.map((d, i) => {
|
|
5641
|
+
const y = i * 28 + 8;
|
|
5642
|
+
const barHeight = 18;
|
|
5643
|
+
const w = d.count / max * (width - labelWidth - 60);
|
|
5644
|
+
const color = d.isProject ? COLORS.accent : COLORS.series[(i + 1) % COLORS.series.length];
|
|
5645
|
+
return `
|
|
5646
|
+
<text x="${labelWidth - 8}" y="${y + 13}" fill="${COLORS.textMuted}" font-size="11" text-anchor="end">${escapeHtml(d.label)}</text>
|
|
5647
|
+
<rect x="${labelWidth}" y="${y}" width="${w}" height="${barHeight}" fill="${color}" rx="3" />
|
|
5648
|
+
<text x="${labelWidth + w + 6}" y="${y + 13}" fill="${COLORS.text}" font-size="11">${d.count}</text>`;
|
|
5649
|
+
}).join("");
|
|
5650
|
+
return `<div class="chart-card">
|
|
5651
|
+
<h3>Citations per domain</h3>
|
|
5652
|
+
<svg viewBox="0 0 ${width} ${height}" width="100%" preserveAspectRatio="xMinYMin meet" role="img" aria-label="Citations per domain bar chart">
|
|
5653
|
+
${bars}
|
|
5654
|
+
</svg>
|
|
5655
|
+
</div>`;
|
|
5656
|
+
}
|
|
5657
|
+
function renderCompetitorLandscape(report) {
|
|
5658
|
+
const competitors2 = report.competitorLandscape.competitors;
|
|
5659
|
+
if (competitors2.length === 0 && report.competitorLandscape.projectCitationCount === 0) {
|
|
5660
|
+
return section(
|
|
5661
|
+
{ id: "competitor-landscape", eyebrow: "Section 3", title: "Competitor Landscape" },
|
|
5662
|
+
renderEmpty("No competitor data yet. Add competitors and run a visibility sweep.")
|
|
5663
|
+
);
|
|
5664
|
+
}
|
|
5665
|
+
const rows = competitors2.map((c) => {
|
|
5666
|
+
const tone = pressureTone(c.pressureLabel);
|
|
5667
|
+
return `<tr>
|
|
5668
|
+
<td>${escapeHtml(c.domain)}</td>
|
|
5669
|
+
<td><span class="badge tone-${tone}">${escapeHtml(c.pressureLabel)}</span></td>
|
|
5670
|
+
<td class="numeric">${c.citationCount} / ${c.totalCount}</td>
|
|
5671
|
+
<td>${escapeHtml(c.citedKeywords.slice(0, 5).join(", "))}${c.citedKeywords.length > 5 ? "\u2026" : ""}</td>
|
|
5672
|
+
</tr>`;
|
|
5673
|
+
}).join("");
|
|
5674
|
+
const table = competitors2.length > 0 ? `<table class="report-table">
|
|
5675
|
+
<thead><tr><th>Domain</th><th>Pressure</th><th>Citations</th><th>Cited keywords</th></tr></thead>
|
|
5676
|
+
<tbody>${rows}</tbody>
|
|
5677
|
+
</table>` : renderEmpty("No competitors configured.");
|
|
5678
|
+
return section(
|
|
5679
|
+
{ id: "competitor-landscape", eyebrow: "Section 3", title: "Competitor Landscape", intro: "Where tracked competitors appear in AI answers compared to your domain." },
|
|
5680
|
+
`${renderCompetitorBars(report.competitorLandscape, report.meta.project.canonicalDomain)}${table}`
|
|
5681
|
+
);
|
|
5682
|
+
}
|
|
5683
|
+
function renderDonut(buckets) {
|
|
5684
|
+
if (buckets.length === 0) return "";
|
|
5685
|
+
const total = buckets.reduce((s, b) => s + b.count, 0);
|
|
5686
|
+
if (total === 0) return "";
|
|
5687
|
+
const cx = 110;
|
|
5688
|
+
const cy = 110;
|
|
5689
|
+
const r = 80;
|
|
5690
|
+
const innerR = 48;
|
|
5691
|
+
let cumulative = 0;
|
|
5692
|
+
const slices = [];
|
|
5693
|
+
const legend = [];
|
|
5694
|
+
buckets.forEach((b, i) => {
|
|
5695
|
+
const startAngle = cumulative / total * Math.PI * 2 - Math.PI / 2;
|
|
5696
|
+
const endAngle = (cumulative + b.count) / total * Math.PI * 2 - Math.PI / 2;
|
|
5697
|
+
cumulative += b.count;
|
|
5698
|
+
const x1 = cx + Math.cos(startAngle) * r;
|
|
5699
|
+
const y1 = cy + Math.sin(startAngle) * r;
|
|
5700
|
+
const x2 = cx + Math.cos(endAngle) * r;
|
|
5701
|
+
const y2 = cy + Math.sin(endAngle) * r;
|
|
5702
|
+
const ix1 = cx + Math.cos(endAngle) * innerR;
|
|
5703
|
+
const iy1 = cy + Math.sin(endAngle) * innerR;
|
|
5704
|
+
const ix2 = cx + Math.cos(startAngle) * innerR;
|
|
5705
|
+
const iy2 = cy + Math.sin(startAngle) * innerR;
|
|
5706
|
+
const largeArc = endAngle - startAngle > Math.PI ? 1 : 0;
|
|
5707
|
+
const color = COLORS.series[i % COLORS.series.length];
|
|
5708
|
+
if (b.count > 0) {
|
|
5709
|
+
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}" />`);
|
|
5710
|
+
legend.push(`<span><span class="legend-swatch" style="background:${color}"></span>${escapeHtml(b.label)} (${b.count})</span>`);
|
|
5711
|
+
}
|
|
5712
|
+
});
|
|
5713
|
+
return `<div class="chart-card">
|
|
5714
|
+
<h3>AI source categories</h3>
|
|
5715
|
+
<div style="display:flex;align-items:center;gap:24px;flex-wrap:wrap;">
|
|
5716
|
+
<svg viewBox="0 0 220 220" width="220" height="220" role="img" aria-label="AI source category donut chart">
|
|
5717
|
+
${slices.join("")}
|
|
5718
|
+
</svg>
|
|
5719
|
+
<div class="legend" style="flex-direction:column;align-items:flex-start;gap:6px;">${legend.join("")}</div>
|
|
5720
|
+
</div>
|
|
5721
|
+
</div>`;
|
|
5722
|
+
}
|
|
5723
|
+
function renderAiSourceOrigin(report) {
|
|
5724
|
+
const origin = report.aiSourceOrigin;
|
|
5725
|
+
if (origin.categories.length === 0 && origin.topDomains.length === 0) {
|
|
5726
|
+
return section(
|
|
5727
|
+
{ id: "ai-source-origin", eyebrow: "Section 4", title: "AI Source Origin" },
|
|
5728
|
+
renderEmpty("No source data yet. Run a visibility sweep first.")
|
|
5729
|
+
);
|
|
5730
|
+
}
|
|
5731
|
+
const rows = origin.topDomains.map((d) => `
|
|
5732
|
+
<tr>
|
|
5733
|
+
<td>${escapeHtml(d.domain)}</td>
|
|
5734
|
+
<td class="numeric">${d.count}</td>
|
|
5735
|
+
<td>${d.isCompetitor ? '<span class="badge tone-negative">Competitor</span>' : '<span class="badge tone-neutral">External</span>'}</td>
|
|
5736
|
+
</tr>`).join("");
|
|
5737
|
+
const table = origin.topDomains.length > 0 ? `<table class="report-table">
|
|
5738
|
+
<thead><tr><th>Domain</th><th>Citations</th><th>Tag</th></tr></thead>
|
|
5739
|
+
<tbody>${rows}</tbody>
|
|
5740
|
+
</table>` : "";
|
|
5741
|
+
return section(
|
|
5742
|
+
{ id: "ai-source-origin", eyebrow: "Section 4", title: "AI Source Origin", intro: "Where AI answers pull from, aggregated across the latest sweep." },
|
|
5743
|
+
`${renderDonut(origin.categories)}${table}`
|
|
5744
|
+
);
|
|
5745
|
+
}
|
|
5746
|
+
function renderLineChart(points, color, title, height = 200) {
|
|
5747
|
+
if (points.length === 0) return "";
|
|
5748
|
+
const width = 600;
|
|
5749
|
+
const padX = 32;
|
|
5750
|
+
const padY = 24;
|
|
5751
|
+
const usableW = width - padX * 2;
|
|
5752
|
+
const usableH = height - padY * 2;
|
|
5753
|
+
const max = Math.max(...points.map((p) => p.y), 1);
|
|
5754
|
+
const stepX = points.length > 1 ? usableW / (points.length - 1) : 0;
|
|
5755
|
+
const xy = points.map((p, i) => ({
|
|
5756
|
+
x: padX + i * stepX,
|
|
5757
|
+
y: padY + usableH - p.y / max * usableH,
|
|
5758
|
+
raw: p
|
|
5759
|
+
}));
|
|
5760
|
+
const path10 = xy.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x.toFixed(1)} ${p.y.toFixed(1)}`).join(" ");
|
|
5761
|
+
const dots = xy.map((p) => `<circle cx="${p.x.toFixed(1)}" cy="${p.y.toFixed(1)}" r="3" fill="${color}" />`).join("");
|
|
5762
|
+
const xLabels = xy.map((p, i) => {
|
|
5763
|
+
if (points.length > 8 && i % Math.ceil(points.length / 6) !== 0 && i !== points.length - 1) return "";
|
|
5764
|
+
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>`;
|
|
5765
|
+
}).join("");
|
|
5766
|
+
return `<div class="chart-card">
|
|
5767
|
+
<h3>${escapeHtml(title)}</h3>
|
|
5768
|
+
<svg viewBox="0 0 ${width} ${height}" width="100%" preserveAspectRatio="xMinYMin meet" role="img" aria-label="${escapeHtml(title)} line chart">
|
|
5769
|
+
<line x1="${padX}" y1="${padY + usableH}" x2="${padX + usableW}" y2="${padY + usableH}" stroke="${COLORS.border}" stroke-width="1" />
|
|
5770
|
+
<text x="${padX - 6}" y="${(padY + 4).toFixed(1)}" fill="${COLORS.textFaint}" font-size="9" text-anchor="end">${formatNumber(max)}</text>
|
|
5771
|
+
<text x="${padX - 6}" y="${(padY + usableH).toFixed(1)}" fill="${COLORS.textFaint}" font-size="9" text-anchor="end">0</text>
|
|
5772
|
+
<path d="${path10}" stroke="${color}" stroke-width="2" fill="none" />
|
|
5773
|
+
${dots}
|
|
5774
|
+
${xLabels}
|
|
5775
|
+
</svg>
|
|
5776
|
+
</div>`;
|
|
5777
|
+
}
|
|
5778
|
+
function renderGsc(report) {
|
|
5779
|
+
const gsc = report.gsc;
|
|
5780
|
+
if (!gsc) {
|
|
5781
|
+
return section(
|
|
5782
|
+
{ id: "gsc", eyebrow: "Section 5", title: "GSC Performance" },
|
|
5783
|
+
renderEmpty("Connect Google Search Console to populate this section.")
|
|
5784
|
+
);
|
|
5785
|
+
}
|
|
5786
|
+
const rows = gsc.topQueries.map((q) => `
|
|
5787
|
+
<tr>
|
|
5788
|
+
<td>${escapeHtml(q.query)}</td>
|
|
5789
|
+
<td class="numeric">${formatNumber(q.clicks)}</td>
|
|
5790
|
+
<td class="numeric">${formatNumber(q.impressions)}</td>
|
|
5791
|
+
<td class="numeric">${formatRatio(q.ctr)}</td>
|
|
5792
|
+
<td class="numeric">${q.avgPosition.toFixed(1)}</td>
|
|
5793
|
+
<td><span class="badge tone-neutral">${escapeHtml(q.category)}</span></td>
|
|
5794
|
+
</tr>`).join("");
|
|
5795
|
+
const breakdownRows = gsc.categoryBreakdown.map((c) => `
|
|
5796
|
+
<tr>
|
|
5797
|
+
<td>${escapeHtml(c.category)}</td>
|
|
5798
|
+
<td class="numeric">${formatNumber(c.clicks)}</td>
|
|
5799
|
+
<td class="numeric">${formatNumber(c.impressions)}</td>
|
|
5800
|
+
<td class="numeric">${c.sharePct}%</td>
|
|
5801
|
+
</tr>`).join("");
|
|
5802
|
+
const trendChart = renderLineChart(
|
|
5803
|
+
gsc.trend.map((t) => ({ x: t.date, y: t.clicks, label: t.date.slice(5) })),
|
|
5804
|
+
COLORS.accent,
|
|
5805
|
+
"Clicks over time"
|
|
5806
|
+
);
|
|
5807
|
+
return section(
|
|
5808
|
+
{ id: "gsc", eyebrow: "Section 5", title: "GSC Performance", intro: "Top queries, category breakdown, and traffic trend from Google Search Console." },
|
|
5809
|
+
`<div class="metric-grid">
|
|
5810
|
+
<div class="metric"><div class="label">Total clicks</div><div class="value">${formatNumber(gsc.totalClicks)}</div></div>
|
|
5811
|
+
<div class="metric"><div class="label">Total impressions</div><div class="value">${formatNumber(gsc.totalImpressions)}</div></div>
|
|
5812
|
+
<div class="metric"><div class="label">Avg CTR</div><div class="value">${formatRatio(gsc.ctr)}</div></div>
|
|
5813
|
+
<div class="metric"><div class="label">Avg position</div><div class="value">${gsc.avgPosition.toFixed(1)}</div></div>
|
|
5814
|
+
</div>
|
|
5815
|
+
${trendChart}
|
|
5816
|
+
<div class="chart-card"><h3>Top queries</h3>
|
|
5817
|
+
<table class="report-table">
|
|
5818
|
+
<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>
|
|
5819
|
+
<tbody>${rows}</tbody>
|
|
5820
|
+
</table>
|
|
5821
|
+
</div>
|
|
5822
|
+
<div class="chart-card"><h3>Category breakdown</h3>
|
|
5823
|
+
<table class="report-table">
|
|
5824
|
+
<thead><tr><th>Category</th><th class="numeric">Clicks</th><th class="numeric">Imp.</th><th class="numeric">Share</th></tr></thead>
|
|
5825
|
+
<tbody>${breakdownRows}</tbody>
|
|
5826
|
+
</table>
|
|
5827
|
+
</div>`
|
|
5828
|
+
);
|
|
5829
|
+
}
|
|
5830
|
+
function renderGa(report) {
|
|
5831
|
+
const ga = report.ga;
|
|
5832
|
+
if (!ga) {
|
|
5833
|
+
return section(
|
|
5834
|
+
{ id: "ga", eyebrow: "Section 6", title: "GA4 Traffic" },
|
|
5835
|
+
renderEmpty("Connect Google Analytics 4 to populate this section.")
|
|
5836
|
+
);
|
|
5837
|
+
}
|
|
5838
|
+
const pageRows = ga.topLandingPages.map((p) => `
|
|
5839
|
+
<tr>
|
|
5840
|
+
<td class="page-cell">${formatLandingPageHtml(p.page)}</td>
|
|
5841
|
+
<td class="numeric">${formatNumber(p.sessions)}</td>
|
|
5842
|
+
<td class="numeric">${formatNumber(p.users)}</td>
|
|
5843
|
+
<td class="numeric">${formatNumber(p.organicSessions)}</td>
|
|
5844
|
+
</tr>`).join("");
|
|
5845
|
+
const channelRows = ga.channelBreakdown.map((c) => `
|
|
5846
|
+
<tr>
|
|
5847
|
+
<td>${escapeHtml(c.channel)}</td>
|
|
5848
|
+
<td class="numeric">${formatNumber(c.sessions)}</td>
|
|
5849
|
+
<td class="numeric">${c.sharePct}%</td>
|
|
5850
|
+
</tr>`).join("");
|
|
5851
|
+
return section(
|
|
5852
|
+
{ id: "ga", eyebrow: "Section 6", title: "GA4 Traffic", intro: `Sessions and users for ${formatDate(ga.periodStart)} \u2192 ${formatDate(ga.periodEnd)}.` },
|
|
5853
|
+
`<div class="metric-grid">
|
|
5854
|
+
<div class="metric"><div class="label">Total sessions</div><div class="value">${formatNumber(ga.totalSessions)}</div></div>
|
|
5855
|
+
<div class="metric"><div class="label">Total users</div><div class="value">${formatNumber(ga.totalUsers)}</div></div>
|
|
5856
|
+
<div class="metric"><div class="label">Organic sessions</div><div class="value">${formatNumber(ga.totalOrganicSessions)}</div></div>
|
|
5857
|
+
</div>
|
|
5858
|
+
<div class="chart-card"><h3>Top landing pages</h3>
|
|
5859
|
+
<table class="report-table">
|
|
5860
|
+
<thead><tr><th>Page</th><th class="numeric">Sessions</th><th class="numeric">Users</th><th class="numeric">Organic</th></tr></thead>
|
|
5861
|
+
<tbody>${pageRows}</tbody>
|
|
5862
|
+
</table>
|
|
5863
|
+
</div>
|
|
5864
|
+
<div class="chart-card"><h3>Channel breakdown</h3>
|
|
5865
|
+
<table class="report-table">
|
|
5866
|
+
<thead><tr><th>Channel</th><th class="numeric">Sessions</th><th class="numeric">Share</th></tr></thead>
|
|
5867
|
+
<tbody>${channelRows}</tbody>
|
|
5868
|
+
</table>
|
|
5869
|
+
</div>`
|
|
5870
|
+
);
|
|
5871
|
+
}
|
|
5872
|
+
function renderSocial(report) {
|
|
5873
|
+
const social = report.socialReferrals;
|
|
5874
|
+
if (!social) {
|
|
5875
|
+
return section(
|
|
5876
|
+
{ id: "social-referrals", eyebrow: "Section 7", title: "Social Referrals" },
|
|
5877
|
+
renderEmpty("No social referral data yet.")
|
|
5878
|
+
);
|
|
5879
|
+
}
|
|
5880
|
+
const channelRows = social.channels.map((c) => `
|
|
5881
|
+
<tr>
|
|
5882
|
+
<td>${escapeHtml(c.channelGroup)}</td>
|
|
5883
|
+
<td class="numeric">${formatNumber(c.sessions)}</td>
|
|
5884
|
+
<td class="numeric">${c.sharePct}%</td>
|
|
5885
|
+
</tr>`).join("");
|
|
5886
|
+
const campaignRows = social.topCampaigns.map((c) => `
|
|
5887
|
+
<tr>
|
|
5888
|
+
<td>${escapeHtml(c.source)}</td>
|
|
5889
|
+
<td>${escapeHtml(c.medium)}</td>
|
|
5890
|
+
<td class="numeric">${formatNumber(c.sessions)}</td>
|
|
5891
|
+
</tr>`).join("");
|
|
5892
|
+
return section(
|
|
5893
|
+
{ id: "social-referrals", eyebrow: "Section 7", title: "Social Referrals", intro: "Paid vs organic split with top campaigns." },
|
|
5894
|
+
`<div class="metric-grid">
|
|
5895
|
+
<div class="metric"><div class="label">Total sessions</div><div class="value">${formatNumber(social.totalSessions)}</div></div>
|
|
5896
|
+
<div class="metric"><div class="label">Organic social</div><div class="value">${formatNumber(social.organicSessions)}</div></div>
|
|
5897
|
+
<div class="metric"><div class="label">Paid social</div><div class="value">${formatNumber(social.paidSessions)}</div></div>
|
|
5898
|
+
</div>
|
|
5899
|
+
<div class="chart-card"><h3>Channel groups</h3>
|
|
5900
|
+
<table class="report-table">
|
|
5901
|
+
<thead><tr><th>Channel</th><th class="numeric">Sessions</th><th class="numeric">Share</th></tr></thead>
|
|
5902
|
+
<tbody>${channelRows}</tbody>
|
|
5903
|
+
</table>
|
|
5904
|
+
</div>
|
|
5905
|
+
<div class="chart-card"><h3>Top campaigns</h3>
|
|
5906
|
+
<table class="report-table">
|
|
5907
|
+
<thead><tr><th>Source</th><th>Medium</th><th class="numeric">Sessions</th></tr></thead>
|
|
5908
|
+
<tbody>${campaignRows}</tbody>
|
|
5909
|
+
</table>
|
|
5910
|
+
</div>`
|
|
5911
|
+
);
|
|
5912
|
+
}
|
|
5913
|
+
function renderAiReferrals(report) {
|
|
5914
|
+
const ai = report.aiReferrals;
|
|
5915
|
+
if (!ai) {
|
|
5916
|
+
return section(
|
|
5917
|
+
{ id: "ai-referrals", eyebrow: "Section 8", title: "AI Referral Traffic" },
|
|
5918
|
+
renderEmpty("No AI referral traffic detected yet.")
|
|
5919
|
+
);
|
|
5920
|
+
}
|
|
5921
|
+
const sourceRows = ai.bySource.map((s) => `
|
|
5922
|
+
<tr>
|
|
5923
|
+
<td>${escapeHtml(s.source)}</td>
|
|
5924
|
+
<td class="numeric">${formatNumber(s.sessions)}</td>
|
|
5925
|
+
<td class="numeric">${formatNumber(s.users)}</td>
|
|
5926
|
+
<td class="numeric">${s.sharePct}%</td>
|
|
5927
|
+
</tr>`).join("");
|
|
5928
|
+
const pageRows = ai.topLandingPages.map((p) => `
|
|
5929
|
+
<tr>
|
|
5930
|
+
<td class="page-cell">${formatLandingPageHtml(p.page)}</td>
|
|
5931
|
+
<td class="numeric">${formatNumber(p.sessions)}</td>
|
|
5932
|
+
<td class="numeric">${formatNumber(p.users)}</td>
|
|
5933
|
+
</tr>`).join("");
|
|
5934
|
+
const trendChart = renderLineChart(
|
|
5935
|
+
ai.trend.map((t) => ({ x: t.date, y: t.sessions, label: t.date.slice(5) })),
|
|
5936
|
+
COLORS.series[2],
|
|
5937
|
+
"AI referral sessions over time"
|
|
5938
|
+
);
|
|
5939
|
+
return section(
|
|
5940
|
+
{ id: "ai-referrals", eyebrow: "Section 8", title: "AI Referral Traffic", intro: "Sessions sent from AI answer engines." },
|
|
5941
|
+
`<div class="metric-grid">
|
|
5942
|
+
<div class="metric"><div class="label">Total sessions</div><div class="value">${formatNumber(ai.totalSessions)}</div></div>
|
|
5943
|
+
<div class="metric"><div class="label">Total users</div><div class="value">${formatNumber(ai.totalUsers)}</div></div>
|
|
5944
|
+
</div>
|
|
5945
|
+
${trendChart}
|
|
5946
|
+
<div class="chart-card"><h3>Sessions by source</h3>
|
|
5947
|
+
<table class="report-table">
|
|
5948
|
+
<thead><tr><th>Source</th><th class="numeric">Sessions</th><th class="numeric">Users</th><th class="numeric">Share</th></tr></thead>
|
|
5949
|
+
<tbody>${sourceRows}</tbody>
|
|
5950
|
+
</table>
|
|
5951
|
+
</div>
|
|
5952
|
+
<div class="chart-card"><h3>Top AI landing pages</h3>
|
|
5953
|
+
<table class="report-table">
|
|
5954
|
+
<thead><tr><th>Page</th><th class="numeric">Sessions</th><th class="numeric">Users</th></tr></thead>
|
|
5955
|
+
<tbody>${pageRows}</tbody>
|
|
5956
|
+
</table>
|
|
5957
|
+
</div>`
|
|
5958
|
+
);
|
|
5959
|
+
}
|
|
5960
|
+
function renderIndexingHealth(report) {
|
|
5961
|
+
const ih = report.indexingHealth;
|
|
5962
|
+
if (!ih) {
|
|
5963
|
+
return section(
|
|
5964
|
+
{ id: "indexing-health", eyebrow: "Section 9", title: "Indexing Health" },
|
|
5965
|
+
renderEmpty("Connect Google Search Console or Bing Webmaster Tools and run a sitemap inspection.")
|
|
5966
|
+
);
|
|
5967
|
+
}
|
|
5968
|
+
const segments = [
|
|
5969
|
+
{ label: "Indexed", count: ih.indexed, color: COLORS.positive },
|
|
5970
|
+
{ label: "Not indexed", count: ih.notIndexed, color: COLORS.caution },
|
|
5971
|
+
{ label: "Deindexed", count: ih.deindexed, color: COLORS.negative },
|
|
5972
|
+
{ label: "Unknown", count: ih.unknown, color: COLORS.neutral }
|
|
5973
|
+
].filter((s) => s.count > 0);
|
|
5974
|
+
const total = segments.reduce((s, x) => s + x.count, 0) || 1;
|
|
5975
|
+
const width = 600;
|
|
5976
|
+
const height = 28;
|
|
5977
|
+
let acc = 0;
|
|
5978
|
+
const bars = segments.map((s) => {
|
|
5979
|
+
const w = s.count / total * width;
|
|
5980
|
+
const x = acc;
|
|
5981
|
+
acc += w;
|
|
5982
|
+
return `<rect x="${x}" y="0" width="${w}" height="${height}" fill="${s.color}" />`;
|
|
5983
|
+
}).join("");
|
|
5984
|
+
const legend = segments.map((s) => `<span><span class="legend-swatch" style="background:${s.color}"></span>${escapeHtml(s.label)}: ${s.count}</span>`).join("");
|
|
5985
|
+
return section(
|
|
5986
|
+
{ id: "indexing-health", eyebrow: "Section 9", title: "Indexing Health", intro: `Source: ${ih.provider === "google" ? "Google Search Console" : "Bing Webmaster Tools"}.` },
|
|
5987
|
+
`<div class="metric-grid">
|
|
5988
|
+
<div class="metric"><div class="label">Indexed</div><div class="value tone-positive">${formatNumber(ih.indexed)}</div></div>
|
|
5989
|
+
<div class="metric"><div class="label">Total inspected</div><div class="value">${formatNumber(ih.total)}</div></div>
|
|
5990
|
+
<div class="metric"><div class="label">Indexed share</div><div class="value">${ih.indexedPct}%</div></div>
|
|
5991
|
+
</div>
|
|
5992
|
+
<div class="chart-card">
|
|
5993
|
+
<h3>Coverage breakdown</h3>
|
|
5994
|
+
<svg viewBox="0 0 ${width} ${height}" width="100%" preserveAspectRatio="xMinYMin meet" role="img" aria-label="Coverage stacked bar">${bars}</svg>
|
|
5995
|
+
<div class="legend">${legend}</div>
|
|
5996
|
+
</div>`
|
|
5997
|
+
);
|
|
5998
|
+
}
|
|
5999
|
+
function renderCitationsTrend(report) {
|
|
6000
|
+
const trend = report.citationsTrend;
|
|
6001
|
+
if (trend.length === 0) {
|
|
6002
|
+
return section(
|
|
6003
|
+
{ id: "citations-trend", eyebrow: "Section 10", title: "Citations Over Time" },
|
|
6004
|
+
renderEmpty("Run multiple visibility sweeps to see a trend.")
|
|
6005
|
+
);
|
|
6006
|
+
}
|
|
6007
|
+
const chart = renderLineChart(
|
|
6008
|
+
trend.map((t) => ({ x: t.date, y: t.citationRate, label: formatDate(t.date) })),
|
|
6009
|
+
COLORS.positive,
|
|
6010
|
+
"Overall citation rate",
|
|
6011
|
+
220
|
|
6012
|
+
);
|
|
6013
|
+
const rows = trend.map((t) => `
|
|
6014
|
+
<tr>
|
|
6015
|
+
<td>${formatDate(t.date)}</td>
|
|
6016
|
+
<td class="numeric">${t.citationRate}%</td>
|
|
6017
|
+
<td>${t.providerRates.map((r) => `${escapeHtml(r.provider)}: ${r.citationRate}%`).join(" \xB7 ")}</td>
|
|
6018
|
+
</tr>`).join("");
|
|
6019
|
+
return section(
|
|
6020
|
+
{ id: "citations-trend", eyebrow: "Section 10", title: "Citations Over Time", intro: "Per-run citation rate across the project history." },
|
|
6021
|
+
`${chart}
|
|
6022
|
+
<div class="chart-card"><h3>Run-by-run breakdown</h3>
|
|
6023
|
+
<table class="report-table">
|
|
6024
|
+
<thead><tr><th>Run</th><th class="numeric">Overall rate</th><th>Per-provider rates</th></tr></thead>
|
|
6025
|
+
<tbody>${rows}</tbody>
|
|
6026
|
+
</table>
|
|
6027
|
+
</div>`
|
|
6028
|
+
);
|
|
6029
|
+
}
|
|
6030
|
+
function renderInsights(report) {
|
|
6031
|
+
const list = report.insights;
|
|
6032
|
+
if (list.length === 0) {
|
|
6033
|
+
return section(
|
|
6034
|
+
{ id: "insights", eyebrow: "Section 11", title: "Insights & Alerts" },
|
|
6035
|
+
renderEmpty("No insights yet \u2014 run a visibility sweep to generate alerts.")
|
|
6036
|
+
);
|
|
6037
|
+
}
|
|
6038
|
+
const rows = list.map((i) => {
|
|
6039
|
+
const tone = severityTone(i.severity);
|
|
6040
|
+
return `<tr>
|
|
6041
|
+
<td><span class="badge tone-${tone}">${escapeHtml(i.severity)}</span></td>
|
|
6042
|
+
<td>${escapeHtml(i.title)}</td>
|
|
6043
|
+
<td>${escapeHtml(i.keyword)}</td>
|
|
6044
|
+
<td>${escapeHtml(i.provider)}</td>
|
|
6045
|
+
<td>${i.recommendation ? escapeHtml(i.recommendation) : '<span class="cell-pending">\u2014</span>'}</td>
|
|
6046
|
+
</tr>`;
|
|
6047
|
+
}).join("");
|
|
6048
|
+
return section(
|
|
6049
|
+
{ id: "insights", eyebrow: "Section 11", title: "Insights & Alerts", intro: "Priority-ordered findings from the most recent runs." },
|
|
6050
|
+
`<table class="report-table">
|
|
6051
|
+
<thead><tr><th>Severity</th><th>Title</th><th>Keyword</th><th>Provider</th><th>Recommendation</th></tr></thead>
|
|
6052
|
+
<tbody>${rows}</tbody>
|
|
6053
|
+
</table>`
|
|
6054
|
+
);
|
|
6055
|
+
}
|
|
6056
|
+
function renderRecommendedNextSteps(report) {
|
|
6057
|
+
const steps = report.recommendedNextSteps;
|
|
6058
|
+
if (steps.length === 0) {
|
|
6059
|
+
return section(
|
|
6060
|
+
{ id: "recommended-next-steps", eyebrow: "Section 12", title: "Recommended Next Steps" },
|
|
6061
|
+
renderEmpty("No outstanding actions.")
|
|
6062
|
+
);
|
|
6063
|
+
}
|
|
6064
|
+
const items = steps.map((s) => `
|
|
6065
|
+
<div class="step">
|
|
6066
|
+
<span class="horizon">${escapeHtml(s.horizon)}</span>
|
|
6067
|
+
<span class="title">${escapeHtml(s.title)}</span>
|
|
6068
|
+
<span class="rationale">${escapeHtml(s.rationale)}</span>
|
|
6069
|
+
</div>`).join("");
|
|
6070
|
+
return section(
|
|
6071
|
+
{ id: "recommended-next-steps", eyebrow: "Section 12", title: "Recommended Next Steps" },
|
|
6072
|
+
`<div class="steps">${items}</div>`
|
|
6073
|
+
);
|
|
6074
|
+
}
|
|
6075
|
+
function escapeJsonForScript(json) {
|
|
6076
|
+
return json.replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
|
|
6077
|
+
}
|
|
6078
|
+
function renderReportHtml(report, opts = {}) {
|
|
6079
|
+
const title = opts.title ?? `Canonry report \u2014 ${report.meta.project.displayName}`;
|
|
6080
|
+
const sections = [
|
|
6081
|
+
renderExecutiveSummary(report),
|
|
6082
|
+
renderCitationScorecard(report),
|
|
6083
|
+
renderCompetitorLandscape(report),
|
|
6084
|
+
renderAiSourceOrigin(report),
|
|
6085
|
+
renderGsc(report),
|
|
6086
|
+
renderGa(report),
|
|
6087
|
+
renderSocial(report),
|
|
6088
|
+
renderAiReferrals(report),
|
|
6089
|
+
renderIndexingHealth(report),
|
|
6090
|
+
renderCitationsTrend(report),
|
|
6091
|
+
renderInsights(report),
|
|
6092
|
+
renderRecommendedNextSteps(report)
|
|
6093
|
+
].join("\n");
|
|
6094
|
+
const json = escapeJsonForScript(JSON.stringify(report));
|
|
6095
|
+
return `<!DOCTYPE html>
|
|
6096
|
+
<html lang="en">
|
|
6097
|
+
<head>
|
|
6098
|
+
<meta charset="utf-8" />
|
|
6099
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6100
|
+
<title>${escapeHtml(title)}</title>
|
|
6101
|
+
<style>${STYLE}</style>
|
|
6102
|
+
</head>
|
|
6103
|
+
<body>
|
|
6104
|
+
<div class="container">
|
|
6105
|
+
<header class="header">
|
|
6106
|
+
<div class="eyebrow">AEO Report</div>
|
|
6107
|
+
<h1>${escapeHtml(report.meta.project.displayName)}</h1>
|
|
6108
|
+
<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>
|
|
6109
|
+
</header>
|
|
6110
|
+
${sections}
|
|
6111
|
+
<footer class="footer">Generated by canonry \xB7 ${escapeHtml(report.meta.generatedAt)}</footer>
|
|
6112
|
+
</div>
|
|
6113
|
+
<script type="application/json" id="canonry-report-data">${json}</script>
|
|
6114
|
+
</body>
|
|
6115
|
+
</html>`;
|
|
6116
|
+
}
|
|
6117
|
+
|
|
6118
|
+
// src/commands/report.ts
|
|
6119
|
+
function defaultOutputPath(project) {
|
|
6120
|
+
const date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
6121
|
+
return path3.resolve(process.cwd(), `canonry-report-${project}-${date}.html`);
|
|
6122
|
+
}
|
|
6123
|
+
async function runReportCommand(project, opts = {}) {
|
|
6124
|
+
const client = createApiClient();
|
|
6125
|
+
const report = await client.getReport(project);
|
|
6126
|
+
if (opts.format === "json") {
|
|
6127
|
+
console.log(JSON.stringify(report, null, 2));
|
|
6128
|
+
return;
|
|
6129
|
+
}
|
|
6130
|
+
const html = renderReportHtml(report);
|
|
6131
|
+
const targetPath = opts.output ? path3.resolve(opts.output) : defaultOutputPath(project);
|
|
6132
|
+
const dir = path3.dirname(targetPath);
|
|
6133
|
+
if (!fs4.existsSync(dir)) {
|
|
6134
|
+
fs4.mkdirSync(dir, { recursive: true });
|
|
6135
|
+
}
|
|
6136
|
+
fs4.writeFileSync(targetPath, html, "utf-8");
|
|
6137
|
+
console.log(`Report written to ${targetPath}`);
|
|
6138
|
+
}
|
|
6139
|
+
|
|
6140
|
+
// src/cli-commands/report.ts
|
|
6141
|
+
var USAGE2 = "canonry report <project> [--output <path>] [--format json]";
|
|
6142
|
+
var REPORT_CLI_COMMANDS = [
|
|
6143
|
+
{
|
|
6144
|
+
path: ["report"],
|
|
6145
|
+
usage: USAGE2,
|
|
6146
|
+
options: {
|
|
6147
|
+
output: { type: "string", short: "o" }
|
|
6148
|
+
},
|
|
6149
|
+
run: async (input) => {
|
|
6150
|
+
const project = requireProject(input, "report", USAGE2);
|
|
6151
|
+
await runReportCommand(project, {
|
|
6152
|
+
format: input.format,
|
|
6153
|
+
output: getString(input.values, "output")
|
|
6154
|
+
});
|
|
6155
|
+
}
|
|
6156
|
+
}
|
|
6157
|
+
];
|
|
6158
|
+
|
|
5177
6159
|
// src/commands/run.ts
|
|
5178
6160
|
function getClient14() {
|
|
5179
6161
|
return createApiClient();
|
|
@@ -5882,19 +6864,19 @@ Usage: canonry settings provider ${name} --api-key <key> [--model <model>] [--ma
|
|
|
5882
6864
|
];
|
|
5883
6865
|
|
|
5884
6866
|
// src/commands/skills.ts
|
|
5885
|
-
import
|
|
5886
|
-
import
|
|
6867
|
+
import fs5 from "fs";
|
|
6868
|
+
import path4 from "path";
|
|
5887
6869
|
import { fileURLToPath } from "url";
|
|
5888
6870
|
var BUNDLED_SKILL_NAMES = ["canonry-setup", "aero"];
|
|
5889
6871
|
function resolveBundledSkillsRoot(pkgDir) {
|
|
5890
|
-
const here = pkgDir ??
|
|
6872
|
+
const here = pkgDir ?? path4.dirname(fileURLToPath(import.meta.url));
|
|
5891
6873
|
const candidates = [
|
|
5892
|
-
|
|
5893
|
-
|
|
5894
|
-
|
|
6874
|
+
path4.join(here, "../assets/agent-workspace/skills"),
|
|
6875
|
+
path4.join(here, "../../assets/agent-workspace/skills"),
|
|
6876
|
+
path4.join(here, "../../../../skills")
|
|
5895
6877
|
];
|
|
5896
6878
|
for (const candidate of candidates) {
|
|
5897
|
-
if (BUNDLED_SKILL_NAMES.every((name) =>
|
|
6879
|
+
if (BUNDLED_SKILL_NAMES.every((name) => fs5.existsSync(path4.join(candidate, name, "SKILL.md")))) {
|
|
5898
6880
|
return candidate;
|
|
5899
6881
|
}
|
|
5900
6882
|
}
|
|
@@ -5915,17 +6897,17 @@ function parseDescription(content) {
|
|
|
5915
6897
|
function getBundledSkills(pkgDir) {
|
|
5916
6898
|
const root = resolveBundledSkillsRoot(pkgDir);
|
|
5917
6899
|
return BUNDLED_SKILL_NAMES.map((name) => {
|
|
5918
|
-
const skillDir =
|
|
5919
|
-
const skillFile =
|
|
5920
|
-
const content =
|
|
6900
|
+
const skillDir = path4.join(root, name);
|
|
6901
|
+
const skillFile = path4.join(skillDir, "SKILL.md");
|
|
6902
|
+
const content = fs5.readFileSync(skillFile, "utf-8");
|
|
5921
6903
|
return { name, description: parseDescription(content), bundledPath: skillDir };
|
|
5922
6904
|
});
|
|
5923
6905
|
}
|
|
5924
6906
|
function walkRelative(dir, prefix = "") {
|
|
5925
6907
|
const out = [];
|
|
5926
|
-
for (const entry of
|
|
5927
|
-
const rel = prefix ?
|
|
5928
|
-
const full =
|
|
6908
|
+
for (const entry of fs5.readdirSync(dir, { withFileTypes: true })) {
|
|
6909
|
+
const rel = prefix ? path4.join(prefix, entry.name) : entry.name;
|
|
6910
|
+
const full = path4.join(dir, entry.name);
|
|
5929
6911
|
if (entry.isDirectory()) {
|
|
5930
6912
|
out.push(...walkRelative(full, rel));
|
|
5931
6913
|
} else if (entry.isFile()) {
|
|
@@ -5935,33 +6917,33 @@ function walkRelative(dir, prefix = "") {
|
|
|
5935
6917
|
return out.sort();
|
|
5936
6918
|
}
|
|
5937
6919
|
function compareDirContent(srcDir, destDir) {
|
|
5938
|
-
if (!
|
|
5939
|
-
if (!
|
|
6920
|
+
if (!fs5.existsSync(destDir)) return "missing";
|
|
6921
|
+
if (!fs5.statSync(destDir).isDirectory()) return "different";
|
|
5940
6922
|
const srcFiles = walkRelative(srcDir);
|
|
5941
6923
|
const destFiles = walkRelative(destDir);
|
|
5942
6924
|
if (srcFiles.length !== destFiles.length) return "different";
|
|
5943
6925
|
for (let i = 0; i < srcFiles.length; i++) {
|
|
5944
6926
|
if (srcFiles[i] !== destFiles[i]) return "different";
|
|
5945
|
-
const srcBytes =
|
|
5946
|
-
const destBytes =
|
|
6927
|
+
const srcBytes = fs5.readFileSync(path4.join(srcDir, srcFiles[i]));
|
|
6928
|
+
const destBytes = fs5.readFileSync(path4.join(destDir, destFiles[i]));
|
|
5947
6929
|
if (!srcBytes.equals(destBytes)) return "different";
|
|
5948
6930
|
}
|
|
5949
6931
|
return "match";
|
|
5950
6932
|
}
|
|
5951
6933
|
function copyDirRecursive(src, dest) {
|
|
5952
|
-
|
|
5953
|
-
for (const entry of
|
|
5954
|
-
const srcPath =
|
|
5955
|
-
const destPath =
|
|
6934
|
+
fs5.mkdirSync(dest, { recursive: true });
|
|
6935
|
+
for (const entry of fs5.readdirSync(src, { withFileTypes: true })) {
|
|
6936
|
+
const srcPath = path4.join(src, entry.name);
|
|
6937
|
+
const destPath = path4.join(dest, entry.name);
|
|
5956
6938
|
if (entry.isDirectory()) {
|
|
5957
6939
|
copyDirRecursive(srcPath, destPath);
|
|
5958
6940
|
} else if (entry.isFile()) {
|
|
5959
|
-
|
|
6941
|
+
fs5.copyFileSync(srcPath, destPath);
|
|
5960
6942
|
}
|
|
5961
6943
|
}
|
|
5962
6944
|
}
|
|
5963
6945
|
function installClaudeSkill(skill, targetDir, force) {
|
|
5964
|
-
const targetPath =
|
|
6946
|
+
const targetPath = path4.join(targetDir, ".claude", "skills", skill.name);
|
|
5965
6947
|
const compare = compareDirContent(skill.bundledPath, targetPath);
|
|
5966
6948
|
if (compare === "match") {
|
|
5967
6949
|
return {
|
|
@@ -5981,7 +6963,7 @@ function installClaudeSkill(skill, targetDir, force) {
|
|
|
5981
6963
|
});
|
|
5982
6964
|
}
|
|
5983
6965
|
if (compare === "different") {
|
|
5984
|
-
|
|
6966
|
+
fs5.rmSync(targetPath, { recursive: true, force: true });
|
|
5985
6967
|
}
|
|
5986
6968
|
copyDirRecursive(skill.bundledPath, targetPath);
|
|
5987
6969
|
return {
|
|
@@ -5993,18 +6975,18 @@ function installClaudeSkill(skill, targetDir, force) {
|
|
|
5993
6975
|
};
|
|
5994
6976
|
}
|
|
5995
6977
|
function installCodexSymlink(skill, targetDir, force) {
|
|
5996
|
-
const codexPath =
|
|
5997
|
-
const claudePath =
|
|
5998
|
-
const linkTarget =
|
|
5999
|
-
|
|
6978
|
+
const codexPath = path4.join(targetDir, ".codex", "skills", skill.name);
|
|
6979
|
+
const claudePath = path4.join(targetDir, ".claude", "skills", skill.name);
|
|
6980
|
+
const linkTarget = path4.relative(path4.dirname(codexPath), claudePath);
|
|
6981
|
+
fs5.mkdirSync(path4.dirname(codexPath), { recursive: true });
|
|
6000
6982
|
let stat;
|
|
6001
6983
|
try {
|
|
6002
|
-
stat =
|
|
6984
|
+
stat = fs5.lstatSync(codexPath);
|
|
6003
6985
|
} catch {
|
|
6004
6986
|
stat = void 0;
|
|
6005
6987
|
}
|
|
6006
6988
|
if (stat?.isSymbolicLink()) {
|
|
6007
|
-
const existing =
|
|
6989
|
+
const existing = fs5.readlinkSync(codexPath);
|
|
6008
6990
|
if (existing === linkTarget) {
|
|
6009
6991
|
return {
|
|
6010
6992
|
skill: skill.name,
|
|
@@ -6022,8 +7004,8 @@ function installCodexSymlink(skill, targetDir, force) {
|
|
|
6022
7004
|
exitCode: 1
|
|
6023
7005
|
});
|
|
6024
7006
|
}
|
|
6025
|
-
|
|
6026
|
-
|
|
7007
|
+
fs5.unlinkSync(codexPath);
|
|
7008
|
+
fs5.symlinkSync(linkTarget, codexPath);
|
|
6027
7009
|
return {
|
|
6028
7010
|
skill: skill.name,
|
|
6029
7011
|
client: CodingAgents.codex,
|
|
@@ -6041,9 +7023,9 @@ function installCodexSymlink(skill, targetDir, force) {
|
|
|
6041
7023
|
exitCode: 1
|
|
6042
7024
|
});
|
|
6043
7025
|
}
|
|
6044
|
-
|
|
7026
|
+
fs5.rmSync(codexPath, { recursive: true, force: true });
|
|
6045
7027
|
}
|
|
6046
|
-
|
|
7028
|
+
fs5.symlinkSync(linkTarget, codexPath);
|
|
6047
7029
|
return {
|
|
6048
7030
|
skill: skill.name,
|
|
6049
7031
|
client: CodingAgents.codex,
|
|
@@ -6059,7 +7041,7 @@ function buildSummaryMessage(results) {
|
|
|
6059
7041
|
return `Skills install summary: ${parts.join(", ")}.`;
|
|
6060
7042
|
}
|
|
6061
7043
|
async function installSkills(opts = {}) {
|
|
6062
|
-
const targetDir =
|
|
7044
|
+
const targetDir = path4.resolve(opts.dir ?? process.cwd());
|
|
6063
7045
|
const client = opts.client ?? SkillsClients.all;
|
|
6064
7046
|
const force = opts.force ?? false;
|
|
6065
7047
|
const allSkills = getBundledSkills();
|
|
@@ -6075,7 +7057,7 @@ async function installSkills(opts = {}) {
|
|
|
6075
7057
|
});
|
|
6076
7058
|
}
|
|
6077
7059
|
const skillsToInstall = allSkills.filter((s) => requestedNames.includes(s.name));
|
|
6078
|
-
|
|
7060
|
+
fs5.mkdirSync(targetDir, { recursive: true });
|
|
6079
7061
|
const results = [];
|
|
6080
7062
|
for (const skill of skillsToInstall) {
|
|
6081
7063
|
results.push(installClaudeSkill(skill, targetDir, force));
|
|
@@ -6176,12 +7158,12 @@ var SKILLS_CLI_COMMANDS = [
|
|
|
6176
7158
|
];
|
|
6177
7159
|
|
|
6178
7160
|
// src/commands/snapshot.ts
|
|
6179
|
-
import
|
|
6180
|
-
import
|
|
7161
|
+
import fs7 from "fs";
|
|
7162
|
+
import path6 from "path";
|
|
6181
7163
|
|
|
6182
7164
|
// src/snapshot-pdf.ts
|
|
6183
|
-
import
|
|
6184
|
-
import
|
|
7165
|
+
import fs6 from "fs";
|
|
7166
|
+
import path5 from "path";
|
|
6185
7167
|
import { PDFDocument, StandardFonts, rgb } from "pdf-lib";
|
|
6186
7168
|
var PAGE_WIDTH = 612;
|
|
6187
7169
|
var PAGE_HEIGHT = 792;
|
|
@@ -6390,9 +7372,9 @@ async function writeSnapshotPdf(report, outputPath) {
|
|
|
6390
7372
|
renderCompetitors(pdf, report);
|
|
6391
7373
|
renderQueries(pdf, report);
|
|
6392
7374
|
const bytes = await doc.save();
|
|
6393
|
-
const resolvedPath =
|
|
6394
|
-
|
|
6395
|
-
|
|
7375
|
+
const resolvedPath = path5.resolve(outputPath);
|
|
7376
|
+
fs6.mkdirSync(path5.dirname(resolvedPath), { recursive: true });
|
|
7377
|
+
fs6.writeFileSync(resolvedPath, bytes);
|
|
6396
7378
|
return resolvedPath;
|
|
6397
7379
|
}
|
|
6398
7380
|
function renderCover(pdf, report) {
|
|
@@ -6550,9 +7532,9 @@ Markdown saved: ${savedMdPath}`);
|
|
|
6550
7532
|
PDF saved: ${savedPdfPath}`);
|
|
6551
7533
|
}
|
|
6552
7534
|
function writeSnapshotMarkdown(report, outputPath) {
|
|
6553
|
-
const resolvedPath =
|
|
6554
|
-
|
|
6555
|
-
|
|
7535
|
+
const resolvedPath = path6.resolve(outputPath);
|
|
7536
|
+
fs7.mkdirSync(path6.dirname(resolvedPath), { recursive: true });
|
|
7537
|
+
fs7.writeFileSync(resolvedPath, formatSnapshotMarkdown(report), "utf-8");
|
|
6556
7538
|
return resolvedPath;
|
|
6557
7539
|
}
|
|
6558
7540
|
function formatSnapshotMarkdown(report) {
|
|
@@ -7207,7 +8189,7 @@ var CONTENT_CLI_COMMANDS = [
|
|
|
7207
8189
|
|
|
7208
8190
|
// src/commands/bootstrap.ts
|
|
7209
8191
|
import crypto from "crypto";
|
|
7210
|
-
import
|
|
8192
|
+
import path7 from "path";
|
|
7211
8193
|
import { eq as eq2 } from "drizzle-orm";
|
|
7212
8194
|
|
|
7213
8195
|
// ../config/src/index.ts
|
|
@@ -7354,7 +8336,7 @@ async function bootstrapCommand(_opts) {
|
|
|
7354
8336
|
);
|
|
7355
8337
|
}
|
|
7356
8338
|
const configDir = getConfigDir();
|
|
7357
|
-
const databasePath = env.databasePath ||
|
|
8339
|
+
const databasePath = env.databasePath || path7.join(configDir, "data.db");
|
|
7358
8340
|
const existing = configExists();
|
|
7359
8341
|
const existingConfig = existing ? loadConfig() : void 0;
|
|
7360
8342
|
let rawApiKey;
|
|
@@ -7424,10 +8406,10 @@ async function bootstrapCommand(_opts) {
|
|
|
7424
8406
|
|
|
7425
8407
|
// src/commands/daemon.ts
|
|
7426
8408
|
import { spawn } from "child_process";
|
|
7427
|
-
import
|
|
7428
|
-
import
|
|
8409
|
+
import fs8 from "fs";
|
|
8410
|
+
import path8 from "path";
|
|
7429
8411
|
function getPidPath() {
|
|
7430
|
-
return
|
|
8412
|
+
return path8.join(getConfigDir(), "canonry.pid");
|
|
7431
8413
|
}
|
|
7432
8414
|
function isProcessAlive(pid) {
|
|
7433
8415
|
try {
|
|
@@ -7454,8 +8436,8 @@ async function waitForReady(host, port, maxMs = 1e4) {
|
|
|
7454
8436
|
async function startDaemon(opts) {
|
|
7455
8437
|
const pidPath = getPidPath();
|
|
7456
8438
|
const format = opts.format ?? "text";
|
|
7457
|
-
if (
|
|
7458
|
-
const existingPid = parseInt(
|
|
8439
|
+
if (fs8.existsSync(pidPath)) {
|
|
8440
|
+
const existingPid = parseInt(fs8.readFileSync(pidPath, "utf-8").trim(), 10);
|
|
7459
8441
|
if (!isNaN(existingPid) && isProcessAlive(existingPid)) {
|
|
7460
8442
|
throw new CliError({
|
|
7461
8443
|
code: "DAEMON_ALREADY_RUNNING",
|
|
@@ -7466,9 +8448,9 @@ async function startDaemon(opts) {
|
|
|
7466
8448
|
}
|
|
7467
8449
|
});
|
|
7468
8450
|
}
|
|
7469
|
-
|
|
8451
|
+
fs8.unlinkSync(pidPath);
|
|
7470
8452
|
}
|
|
7471
|
-
const cliPath =
|
|
8453
|
+
const cliPath = path8.resolve(new URL(import.meta.url).pathname);
|
|
7472
8454
|
const inSourceMode = new URL(import.meta.url).pathname.endsWith(".ts");
|
|
7473
8455
|
const args = inSourceMode ? ["--import", "tsx", cliPath, "serve"] : [cliPath, "serve"];
|
|
7474
8456
|
if (opts.port) args.push("--port", opts.port);
|
|
@@ -7487,10 +8469,10 @@ async function startDaemon(opts) {
|
|
|
7487
8469
|
});
|
|
7488
8470
|
}
|
|
7489
8471
|
const configDir = getConfigDir();
|
|
7490
|
-
if (!
|
|
7491
|
-
|
|
8472
|
+
if (!fs8.existsSync(configDir)) {
|
|
8473
|
+
fs8.mkdirSync(configDir, { recursive: true });
|
|
7492
8474
|
}
|
|
7493
|
-
|
|
8475
|
+
fs8.writeFileSync(pidPath, String(child.pid), "utf-8");
|
|
7494
8476
|
const port = opts.port ?? "4100";
|
|
7495
8477
|
const host = opts.host ?? "127.0.0.1";
|
|
7496
8478
|
if (format !== "json") {
|
|
@@ -7499,7 +8481,7 @@ async function startDaemon(opts) {
|
|
|
7499
8481
|
const ready = await waitForReady(host, port);
|
|
7500
8482
|
if (!ready) {
|
|
7501
8483
|
try {
|
|
7502
|
-
|
|
8484
|
+
fs8.unlinkSync(pidPath);
|
|
7503
8485
|
} catch {
|
|
7504
8486
|
}
|
|
7505
8487
|
throw new CliError({
|
|
@@ -7531,7 +8513,7 @@ async function startDaemon(opts) {
|
|
|
7531
8513
|
}
|
|
7532
8514
|
function stopDaemon(format = "text") {
|
|
7533
8515
|
const pidPath = getPidPath();
|
|
7534
|
-
if (!
|
|
8516
|
+
if (!fs8.existsSync(pidPath)) {
|
|
7535
8517
|
if (format === "json") {
|
|
7536
8518
|
console.log(JSON.stringify({
|
|
7537
8519
|
stopped: false,
|
|
@@ -7542,7 +8524,7 @@ function stopDaemon(format = "text") {
|
|
|
7542
8524
|
console.log("Canonry is not running (no PID file found)");
|
|
7543
8525
|
return;
|
|
7544
8526
|
}
|
|
7545
|
-
const pid = parseInt(
|
|
8527
|
+
const pid = parseInt(fs8.readFileSync(pidPath, "utf-8").trim(), 10);
|
|
7546
8528
|
if (isNaN(pid)) {
|
|
7547
8529
|
if (format === "json") {
|
|
7548
8530
|
console.log(JSON.stringify({
|
|
@@ -7553,7 +8535,7 @@ function stopDaemon(format = "text") {
|
|
|
7553
8535
|
} else {
|
|
7554
8536
|
console.error("Invalid PID file. Removing it.");
|
|
7555
8537
|
}
|
|
7556
|
-
|
|
8538
|
+
fs8.unlinkSync(pidPath);
|
|
7557
8539
|
return;
|
|
7558
8540
|
}
|
|
7559
8541
|
if (!isProcessAlive(pid)) {
|
|
@@ -7567,12 +8549,12 @@ function stopDaemon(format = "text") {
|
|
|
7567
8549
|
} else {
|
|
7568
8550
|
console.log(`Canonry is not running (stale PID: ${pid}). Cleaning up.`);
|
|
7569
8551
|
}
|
|
7570
|
-
|
|
8552
|
+
fs8.unlinkSync(pidPath);
|
|
7571
8553
|
return;
|
|
7572
8554
|
}
|
|
7573
8555
|
try {
|
|
7574
8556
|
process.kill(pid, "SIGTERM");
|
|
7575
|
-
|
|
8557
|
+
fs8.unlinkSync(pidPath);
|
|
7576
8558
|
if (format === "json") {
|
|
7577
8559
|
console.log(JSON.stringify({
|
|
7578
8560
|
stopped: true,
|
|
@@ -7596,9 +8578,9 @@ function stopDaemon(format = "text") {
|
|
|
7596
8578
|
|
|
7597
8579
|
// src/commands/init.ts
|
|
7598
8580
|
import crypto2 from "crypto";
|
|
7599
|
-
import
|
|
8581
|
+
import fs9 from "fs";
|
|
7600
8582
|
import readline from "readline";
|
|
7601
|
-
import
|
|
8583
|
+
import path9 from "path";
|
|
7602
8584
|
function prompt(question) {
|
|
7603
8585
|
const rl = readline.createInterface({
|
|
7604
8586
|
input: process.stdin,
|
|
@@ -7619,8 +8601,8 @@ var DEFAULT_QUOTA = {
|
|
|
7619
8601
|
var PROJECT_MARKERS = [".git", "canonry.yaml", "canonry.yml", "package.json"];
|
|
7620
8602
|
function cwdLooksLikeProject(dir) {
|
|
7621
8603
|
const home = process.env.HOME ?? "";
|
|
7622
|
-
if (home &&
|
|
7623
|
-
return PROJECT_MARKERS.some((marker) =>
|
|
8604
|
+
if (home && path9.resolve(dir) === path9.resolve(home)) return false;
|
|
8605
|
+
return PROJECT_MARKERS.some((marker) => fs9.existsSync(path9.join(dir, marker)));
|
|
7624
8606
|
}
|
|
7625
8607
|
var DEFAULT_AGENT_MODELS = {
|
|
7626
8608
|
anthropic: "anthropic/claude-sonnet-4-6",
|
|
@@ -7650,8 +8632,8 @@ async function initCommand(opts) {
|
|
|
7650
8632
|
return void 0;
|
|
7651
8633
|
}
|
|
7652
8634
|
const configDir = getConfigDir();
|
|
7653
|
-
if (!
|
|
7654
|
-
|
|
8635
|
+
if (!fs9.existsSync(configDir)) {
|
|
8636
|
+
fs9.mkdirSync(configDir, { recursive: true });
|
|
7655
8637
|
}
|
|
7656
8638
|
const bootstrapEnv = getBootstrapEnv(process.env, {
|
|
7657
8639
|
GEMINI_API_KEY: opts?.geminiKey,
|
|
@@ -7766,7 +8748,7 @@ async function initCommand(opts) {
|
|
|
7766
8748
|
const rawApiKey = `cnry_${crypto2.randomBytes(16).toString("hex")}`;
|
|
7767
8749
|
const keyHash = crypto2.createHash("sha256").update(rawApiKey).digest("hex");
|
|
7768
8750
|
const keyPrefix = rawApiKey.slice(0, 9);
|
|
7769
|
-
const databasePath =
|
|
8751
|
+
const databasePath = path9.join(configDir, "data.db");
|
|
7770
8752
|
const db = createClient(databasePath);
|
|
7771
8753
|
migrate(db);
|
|
7772
8754
|
db.insert(apiKeys).values({
|
|
@@ -8214,7 +9196,7 @@ var SYSTEM_CLI_COMMANDS = [
|
|
|
8214
9196
|
];
|
|
8215
9197
|
|
|
8216
9198
|
// src/cli-commands/wordpress.ts
|
|
8217
|
-
import
|
|
9199
|
+
import fs10 from "fs";
|
|
8218
9200
|
|
|
8219
9201
|
// src/commands/wordpress.ts
|
|
8220
9202
|
function getClient18() {
|
|
@@ -8450,12 +9432,12 @@ async function wordpressSetMeta(project, body) {
|
|
|
8450
9432
|
printPageDetail(result);
|
|
8451
9433
|
}
|
|
8452
9434
|
async function wordpressBulkSetMeta(project, opts) {
|
|
8453
|
-
const
|
|
8454
|
-
const
|
|
8455
|
-
const filePath =
|
|
9435
|
+
const fs11 = await import("fs/promises");
|
|
9436
|
+
const path10 = await import("path");
|
|
9437
|
+
const filePath = path10.resolve(opts.from);
|
|
8456
9438
|
let raw;
|
|
8457
9439
|
try {
|
|
8458
|
-
raw = await
|
|
9440
|
+
raw = await fs11.readFile(filePath, "utf8");
|
|
8459
9441
|
} catch {
|
|
8460
9442
|
throw new CliError({
|
|
8461
9443
|
code: "FILE_READ_ERROR",
|
|
@@ -8552,13 +9534,13 @@ async function wordpressSetSchema(project, body) {
|
|
|
8552
9534
|
printManualAssist(`Schema update for "${body.slug}"`, result);
|
|
8553
9535
|
}
|
|
8554
9536
|
async function wordpressSchemaDeploy(project, opts) {
|
|
8555
|
-
const
|
|
8556
|
-
const
|
|
9537
|
+
const fs11 = await import("fs/promises");
|
|
9538
|
+
const path10 = await import("path");
|
|
8557
9539
|
const yaml = await import("yaml").catch(() => null);
|
|
8558
|
-
const filePath =
|
|
9540
|
+
const filePath = path10.resolve(opts.profile);
|
|
8559
9541
|
let raw;
|
|
8560
9542
|
try {
|
|
8561
|
-
raw = await
|
|
9543
|
+
raw = await fs11.readFile(filePath, "utf8");
|
|
8562
9544
|
} catch {
|
|
8563
9545
|
throw new CliError({
|
|
8564
9546
|
code: "FILE_READ_ERROR",
|
|
@@ -8663,13 +9645,13 @@ async function wordpressOnboard(project, opts) {
|
|
|
8663
9645
|
}
|
|
8664
9646
|
let profileData;
|
|
8665
9647
|
if (opts.profile) {
|
|
8666
|
-
const
|
|
8667
|
-
const
|
|
9648
|
+
const fs11 = await import("fs/promises");
|
|
9649
|
+
const path10 = await import("path");
|
|
8668
9650
|
const yaml = await import("yaml").catch(() => null);
|
|
8669
|
-
const filePath =
|
|
9651
|
+
const filePath = path10.resolve(opts.profile);
|
|
8670
9652
|
let raw;
|
|
8671
9653
|
try {
|
|
8672
|
-
raw = await
|
|
9654
|
+
raw = await fs11.readFile(filePath, "utf8");
|
|
8673
9655
|
} catch {
|
|
8674
9656
|
throw new CliError({
|
|
8675
9657
|
code: "FILE_READ_ERROR",
|
|
@@ -8818,7 +9800,7 @@ function resolveContent(input, command, usage, options) {
|
|
|
8818
9800
|
}
|
|
8819
9801
|
if (contentFile) {
|
|
8820
9802
|
try {
|
|
8821
|
-
return
|
|
9803
|
+
return fs10.readFileSync(contentFile, "utf-8");
|
|
8822
9804
|
} catch (error) {
|
|
8823
9805
|
const message = error instanceof Error ? error.message : String(error);
|
|
8824
9806
|
throw usageError(`Error: could not read --content-file "${contentFile}": ${message}`, {
|
|
@@ -9749,6 +10731,7 @@ var REGISTERED_CLI_COMMANDS = [
|
|
|
9749
10731
|
...BACKLINKS_CLI_COMMANDS,
|
|
9750
10732
|
...SYSTEM_CLI_COMMANDS,
|
|
9751
10733
|
...PROJECT_CLI_COMMANDS,
|
|
10734
|
+
...REPORT_CLI_COMMANDS,
|
|
9752
10735
|
...KEYWORD_CLI_COMMANDS,
|
|
9753
10736
|
...COMPETITOR_CLI_COMMANDS,
|
|
9754
10737
|
...SETTINGS_CLI_COMMANDS,
|
|
@@ -9772,7 +10755,7 @@ var REGISTERED_CLI_COMMANDS = [
|
|
|
9772
10755
|
|
|
9773
10756
|
// src/cli.ts
|
|
9774
10757
|
import { createRequire as createRequire2 } from "module";
|
|
9775
|
-
var
|
|
10758
|
+
var USAGE3 = `
|
|
9776
10759
|
canonry \u2014 AEO monitoring CLI
|
|
9777
10760
|
|
|
9778
10761
|
Usage: canonry <command> [options]
|
|
@@ -9833,7 +10816,7 @@ function extractFormat(cmdArgs) {
|
|
|
9833
10816
|
}
|
|
9834
10817
|
async function runCli(args = process.argv.slice(2)) {
|
|
9835
10818
|
if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
|
|
9836
|
-
console.log(
|
|
10819
|
+
console.log(USAGE3);
|
|
9837
10820
|
return 0;
|
|
9838
10821
|
}
|
|
9839
10822
|
if (args.includes("--version") || args.includes("-v")) {
|