@ainyc/canonry 5.5.0 → 5.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/assets/agent-workspace/skills/aero/SKILL.md +18 -0
  2. package/assets/agent-workspace/skills/aero/references/portfolio-analysis.md +1 -1
  3. package/assets/agent-workspace/skills/aero/soul.md +2 -0
  4. package/assets/agent-workspace/skills/canonry/references/aeo-analysis.md +10 -0
  5. package/assets/agent-workspace/skills/canonry/references/canonry-cli.md +5 -4
  6. package/assets/assets/{AuditHistoryPanel-BMGGxHpw.js → AuditHistoryPanel-DumM4n44.js} +1 -1
  7. package/assets/assets/{BacklinksPage-CS3wJSWe.js → BacklinksPage-DHW-aDYf.js} +1 -1
  8. package/assets/assets/ChartPrimitives-BQC2VH0O.js +1 -0
  9. package/assets/assets/{HistoryPage-Ba9ezeuP.js → HistoryPage-DJgCpcmY.js} +1 -1
  10. package/assets/assets/{MeasurementPropertyPage-Y9eseEv0.js → MeasurementPropertyPage-CblC7DUV.js} +1 -1
  11. package/assets/assets/ProjectPage-BKZyG1S3.js +8 -0
  12. package/assets/assets/{RunRow-DbUtgNUI.js → RunRow-plH2LI7o.js} +1 -1
  13. package/assets/assets/{RunsPage-BBuFIYuS.js → RunsPage-JCSy7P9I.js} +1 -1
  14. package/assets/assets/{SettingsPage-DNnR33D-.js → SettingsPage-B8KXT4YK.js} +1 -1
  15. package/assets/assets/{SiteHealthSection-Cf4KHWp0.js → SiteHealthSection-06pNjngi.js} +3 -3
  16. package/assets/assets/{TrafficPage-DSQjaZM9.js → TrafficPage-BYLDNQ1A.js} +5 -5
  17. package/assets/assets/{TrafficSourceDetailPage-C-Pg69TW.js → TrafficSourceDetailPage-CxseBpYr.js} +1 -1
  18. package/assets/assets/{extract-error-message-DNLmL0ID.js → extract-error-message-Dv5fwiqy.js} +1 -1
  19. package/assets/assets/index-2Ldc0Hq4.js +86 -0
  20. package/assets/assets/index-neNKCg78.css +1 -0
  21. package/assets/assets/{react-sigma_core.esm.min-DkItaBPY.js → react-sigma_core.esm.min-C8381RhL.js} +1 -1
  22. package/assets/assets/v2-overview-adapter-BQD1Ap8K.js +1 -0
  23. package/assets/assets/vendor-markdown-B4giJcws.js +29 -0
  24. package/assets/assets/{vendor-recharts-D-cUuLZo.js → vendor-recharts-llkUFBGR.js} +1 -1
  25. package/assets/index.html +4 -4
  26. package/dist/{chunk-BL3FMPSM.js → chunk-5E4ASNU4.js} +857 -784
  27. package/dist/{chunk-75ECQ77U.js → chunk-BO53QARL.js} +49 -16
  28. package/dist/{chunk-REQEWYNF.js → chunk-D3DKDTXP.js} +1027 -70
  29. package/dist/{chunk-DX4CZNNU.js → chunk-LR7BVM53.js} +2 -2
  30. package/dist/{chunk-LQAOGFFK.js → chunk-R4COV5KR.js} +2 -2
  31. package/dist/{chunk-KKDXJWDK.js → chunk-S4DBS5DP.js} +4 -4
  32. package/dist/cli.js +10 -9
  33. package/dist/{demo-server-BSH6SSWD.js → demo-server-WRVVNKLS.js} +7 -3
  34. package/dist/index.d.ts +20 -8
  35. package/dist/index.js +5 -5
  36. package/dist/{intelligence-service-CU7W5AVC.js → intelligence-service-4XMMO3JK.js} +2 -2
  37. package/dist/mcp.js +3 -3
  38. package/package.json +11 -11
  39. package/assets/assets/ChartPrimitives-Bxa_vFmq.js +0 -1
  40. package/assets/assets/ProjectPage-Cl1N9Dcw.js +0 -8
  41. package/assets/assets/index-BCCrbno5.js +0 -86
  42. package/assets/assets/index-gQj7x9RX.css +0 -1
  43. package/assets/assets/v2-overview-adapter-DKBMcYBh.js +0 -1
  44. package/assets/assets/vendor-markdown-B-Oq2NYq.js +0 -14
@@ -88,8 +88,11 @@ import {
88
88
  MeasurementRunScopeError,
89
89
  PROJECTS_WRITE_SCOPE,
90
90
  READ_ONLY_SCOPE,
91
+ REPORT_HEADER_COPY,
92
+ REPORT_SECTION_COPY,
91
93
  REPORT_VISIBILITY_COPY,
92
94
  RESEARCH_RUN_SCOPE,
95
+ ReportSectionIds,
93
96
  ResearchQueryStatuses,
94
97
  ResearchRunStatuses,
95
98
  RunKinds,
@@ -127,6 +130,7 @@ import {
127
130
  USAGE_TELEMETRY_HEADERS,
128
131
  UserRoles,
129
132
  VerificationStatuses,
133
+ VisibilityReportScopeErrorReasons,
130
134
  WILDCARD_SCOPE,
131
135
  WinnabilityClasses,
132
136
  __export,
@@ -270,7 +274,6 @@ import {
270
274
  createTemplateLinkPairIndex,
271
275
  createUserRequestSchema,
272
276
  createdApiKeyDtoSchema,
273
- dedupeReportActions,
274
277
  dedupeReportOpportunities,
275
278
  deduplicateResearchQueries,
276
279
  deliveryFailed,
@@ -282,6 +285,7 @@ import {
282
285
  deriveSiteHealthState,
283
286
  deriveWinnabilityClass,
284
287
  describeError,
288
+ describeLandingPage,
285
289
  determineAnswerMentioned,
286
290
  diagnosticIdentity,
287
291
  discoveryBucketSchema,
@@ -310,16 +314,15 @@ import {
310
314
  findDuplicateLocationLabels,
311
315
  forbidden,
312
316
  formatAiReferralClassSummary,
313
- formatAverageDelta,
314
317
  formatCanonryPluginUpdateHint,
315
318
  formatCanonryPluginVersionMismatch,
316
319
  formatDate,
317
- formatDateRange,
318
320
  formatDeltaCopy,
319
321
  formatIsoDate,
320
322
  formatIsoDateInTimeZone,
321
323
  formatNumber,
322
324
  formatRatio,
325
+ formatWholePercent,
323
326
  formatWindowCountDelta,
324
327
  ga4AiReferralDailyDtoSchema,
325
328
  ga4AiReferralHistoryEntrySchema,
@@ -573,12 +576,68 @@ import {
573
576
  registrableDomain,
574
577
  relativeChangeRatio,
575
578
  reportActionCategoryLabel,
579
+ reportActionConfidenceBadge,
580
+ reportActionHorizonBadge,
576
581
  reportActionTone,
582
+ reportAudienceActions,
583
+ reportBarChartLabel,
584
+ reportCitationsTrendBaseline,
585
+ reportCitedUrlCount,
586
+ reportClientCitedSubtitle,
587
+ reportClientClicksNoun,
588
+ reportClientHeroSentence,
589
+ reportClientIndexedPages,
590
+ reportClientIndexingTone,
591
+ reportClientMentionedSubtitle,
592
+ reportClientNotIndexedTail,
593
+ reportClientProvidersSubtitle,
594
+ reportClientQueriesSubtitle,
595
+ reportClientSearchCount,
596
+ reportClientSourceCount,
597
+ reportClientTrendCopy,
598
+ reportCompactList,
577
599
  reportComparisonWindowDays,
578
- reportConfidenceLabel,
579
- reportHorizonLabel,
600
+ reportCompetitorMentionCopy,
601
+ reportCrawlerTrustSummary,
602
+ reportDeltaArrow,
603
+ reportDirectionTone,
604
+ reportExecutiveHeadline,
605
+ reportGaIntro,
606
+ reportGscIntro,
607
+ reportHeaderMarketLabel,
608
+ reportHeaderPeriodLabel,
609
+ reportIndexingIntro,
610
+ reportIndexingLegendLabel,
611
+ reportInsightTone,
612
+ reportInstanceCountLabel,
613
+ reportLineChartLabel,
614
+ reportMarketScope,
615
+ reportMissRateLabel,
616
+ reportMoreChipLabel,
617
+ reportMovementChangeCopy,
618
+ reportOpportunityActionLine,
619
+ reportPressureTone,
620
+ reportPriorWindowLabel,
621
+ reportProviderDisplayName,
622
+ reportProviderRateLabel,
580
623
  reportQueryClassLabel,
624
+ reportRateDeltaCopy,
625
+ reportReferralRedirectNote,
626
+ reportServerActivityAgencyOperatorHeaders,
627
+ reportServerActivityAgencyTiles,
628
+ reportServerActivityClientOperatorHeaders,
629
+ reportServerActivityCrawledPathsNote,
630
+ reportServerActivityHeading,
631
+ reportServerActivityOperatorDelta,
632
+ reportServerActivityPathHits,
633
+ reportServerActivityTrendTitle,
581
634
  reportSeverityLabel,
635
+ reportShareBarShareLabel,
636
+ reportSourceCategoryShareLabel,
637
+ reportSourceCategoryTone,
638
+ reportSourceOriginHeadline,
639
+ reportTrendProviderRates,
640
+ reportTruncatedList,
582
641
  reportVisibilityComparison,
583
642
  reportVisibilityEvidence,
584
643
  reportVisibilityHistoryLabel,
@@ -612,6 +671,7 @@ import {
612
671
  runTriggerRequestSchema,
613
672
  runTriggerSchema,
614
673
  runtimeStateMissing,
674
+ safeLinkHref,
615
675
  schedulableRunKindSchema,
616
676
  scheduleDtoSchema,
617
677
  scheduleExpectedUpdatedAtSchema,
@@ -709,10 +769,10 @@ import {
709
769
  wordpressSchemaDeployResultDtoSchema,
710
770
  wordpressSchemaStatusResultDtoSchema,
711
771
  wordpressStatusDtoSchema
712
- } from "./chunk-REQEWYNF.js";
772
+ } from "./chunk-D3DKDTXP.js";
713
773
 
714
774
  // src/intelligence-service.ts
715
- import { eq as eq68, desc as desc30, asc as asc12, and as and58, ne as ne11, or as or16, inArray as inArray26, gte as gte18, lte as lte15, isNull as isNull12, sql as sql27, exists } from "drizzle-orm";
775
+ import { eq as eq68, desc as desc30, asc as asc12, and as and58, ne as ne10, or as or17, inArray as inArray26, gte as gte18, lte as lte15, isNull as isNull12, sql as sql27, exists } from "drizzle-orm";
716
776
 
717
777
  // ../db/src/client.ts
718
778
  import { mkdirSync } from "fs";
@@ -7346,6 +7406,26 @@ var MIGRATION_VERSIONS = [
7346
7406
  statements: [
7347
7407
  `ALTER TABLE doctor_health_state ADD COLUMN failing_signature TEXT`
7348
7408
  ]
7409
+ },
7410
+ {
7411
+ version: 158,
7412
+ name: "deepinfra-agent-default-model",
7413
+ // Move the previous agent default once. A later explicit GLM selection
7414
+ // must survive subsequent boots and session hydration.
7415
+ //
7416
+ // A run hook rather than a statement for two reasons. The statement
7417
+ // allowlist is schema-shaped and this rewrites VALUES, and a supported
7418
+ // legacy database can reach this version with no `agent_sessions` at
7419
+ // all: one stamped in `_migrations` at a version above 38 resumes past
7420
+ // the migration that introduces the table, so an unguarded UPDATE dies
7421
+ // with `no such table`.
7422
+ statements: [],
7423
+ run: (tx) => {
7424
+ if (!tableExists(tx, "agent_sessions")) return;
7425
+ tx.run(sql2.raw(
7426
+ `UPDATE agent_sessions SET model_id = 'deepseek-ai/DeepSeek-V4-Flash' WHERE model_provider = 'deepinfra' AND model_id = 'zai-org/GLM-5.2'`
7427
+ ));
7428
+ }
7349
7429
  }
7350
7430
  ];
7351
7431
  function addRunsMeasurementPlanVersionForeignKey(tx) {
@@ -11285,6 +11365,22 @@ function requireAdminSession(request) {
11285
11365
  if (!role || role === UserRoles.admin) return;
11286
11366
  throw forbidden(ADMIN_ONLY_MESSAGE);
11287
11367
  }
11368
+ var NARROW_KEY_DENIED_MESSAGE = "This API key is narrower than the install, so it cannot use an administrator surface.";
11369
+ function isInstanceAdministrator(request) {
11370
+ const principal = request.principal;
11371
+ if (!principal) return true;
11372
+ const role = principal.kind === "user" ? principal.role : principal.delegatedUser?.role;
11373
+ if (role && role !== UserRoles.admin) return false;
11374
+ return !principal.projectId && principal.scopes.includes(WILDCARD_SCOPE);
11375
+ }
11376
+ function requireInstanceAdministrator(request) {
11377
+ const principal = request.principal;
11378
+ if (!principal) return;
11379
+ const role = principal.kind === "user" ? principal.role : principal.delegatedUser?.role;
11380
+ if (role && role !== UserRoles.admin) throw forbidden(ADMIN_ONLY_MESSAGE);
11381
+ if (isInstanceAdministrator(request)) return;
11382
+ throw forbidden(NARROW_KEY_DENIED_MESSAGE);
11383
+ }
11288
11384
  function requireOperator(request) {
11289
11385
  const principal = request.principal;
11290
11386
  if (request.operatorAccess === true && principal?.kind === "api-key" && !principal.viaCookie && !principal.projectId && !principal.delegatedUser) return;
@@ -22786,7 +22882,59 @@ async function measurementPortfolioReadRoutes(app) {
22786
22882
 
22787
22883
  // ../api-routes/src/visibility-report.ts
22788
22884
  import { createHash as createHash5 } from "crypto";
22789
- import { and as and19, desc as desc7, eq as eq24, gte as gte4, inArray as inArray9, isNotNull, isNull as isNull4, lte as lte4, ne as ne3 } from "drizzle-orm";
22885
+ import { and as and19, desc as desc7, eq as eq24, gte as gte4, inArray as inArray9, isNotNull, isNull as isNull4, lt as lt4, lte as lte4, or as or4 } from "drizzle-orm";
22886
+
22887
+ // ../api-routes/src/measurement-scope-options.ts
22888
+ function marketLinks(keys, settings) {
22889
+ if (!settings.marketLinks) return {};
22890
+ const marketKeys = [...new Set(keys ?? [])].sort();
22891
+ return marketKeys.length === 0 ? {} : { marketKeys };
22892
+ }
22893
+ function planScopeOptions(plan, settings) {
22894
+ const groupKeysForTarget = new Map(plan.targets.map((target) => [target.stableKey, []]));
22895
+ for (const group of plan.groups) {
22896
+ for (const targetKey of group.targetKeys) groupKeysForTarget.get(targetKey)?.push(group.stableKey);
22897
+ }
22898
+ const marketKeysForGroup = /* @__PURE__ */ new Map();
22899
+ const marketKeysForTarget = new Map(plan.targets.map((target) => [target.stableKey, []]));
22900
+ for (const market of plan.reportingScopes ?? []) {
22901
+ if (market.groupKey !== void 0) {
22902
+ const keys = marketKeysForGroup.get(market.groupKey) ?? [];
22903
+ keys.push(market.stableKey);
22904
+ marketKeysForGroup.set(market.groupKey, keys);
22905
+ }
22906
+ for (const edge of market.usageEdges) marketKeysForTarget.get(edge.targetKey)?.push(market.stableKey);
22907
+ }
22908
+ return [
22909
+ { id: "project", label: "Project", kind: "project", targetCount: plan.targets.length },
22910
+ ...plan.groups.map((group) => ({
22911
+ id: group.stableKey,
22912
+ label: group.label,
22913
+ kind: "group",
22914
+ targetCount: group.targetKeys.length,
22915
+ ...group.parentGroupKey === void 0 ? {} : { parentGroupIds: [group.parentGroupKey] },
22916
+ ...marketLinks(marketKeysForGroup.get(group.stableKey), settings)
22917
+ })),
22918
+ ...(plan.reportingScopes ?? []).map((market) => ({
22919
+ id: market.stableKey,
22920
+ label: market.label,
22921
+ kind: "market",
22922
+ targetCount: new Set(market.usageEdges.map((edge) => edge.targetKey)).size,
22923
+ ...market.groupKey === void 0 ? {} : { parentGroupIds: [market.groupKey] }
22924
+ })),
22925
+ ...plan.targets.map((target) => ({
22926
+ id: target.stableKey,
22927
+ label: target.label,
22928
+ kind: "property",
22929
+ targetCount: 1,
22930
+ parentGroupIds: [...new Set(groupKeysForTarget.get(target.stableKey) ?? [])].sort(),
22931
+ ...marketLinks(marketKeysForTarget.get(target.stableKey), settings)
22932
+ }))
22933
+ ];
22934
+ }
22935
+ function simpleScopeOptions() {
22936
+ return [{ id: "project", label: "Project", kind: "project", targetCount: 1 }];
22937
+ }
22790
22938
 
22791
22939
  // ../api-routes/src/visibility-report-reader.ts
22792
22940
  import { createHash as createHash4 } from "crypto";
@@ -22797,9 +22945,12 @@ var VisibilityReportCursorError = class extends Error {
22797
22945
  }
22798
22946
  };
22799
22947
  var VisibilityReportScopeError = class extends Error {
22800
- constructor(message) {
22948
+ /** Set only when the selected scope or market key is absent from the frozen definition. */
22949
+ details;
22950
+ constructor(message, details) {
22801
22951
  super(message);
22802
22952
  this.name = "VisibilityReportScopeError";
22953
+ this.details = details;
22803
22954
  }
22804
22955
  };
22805
22956
  var ALL_CLASSES = ["branded", "non-brand", "unknown"];
@@ -22840,13 +22991,13 @@ function scopeResolution(definition, selection) {
22840
22991
  const key = selection.scopeKey;
22841
22992
  if (!key) throw new VisibilityReportScopeError(`${selection.scope} scope requires a scope key.`);
22842
22993
  const scoped = definition.scopeOptions.find((candidate) => candidate.kind === selection.scope && candidate.id === key);
22843
- if (!scoped) throw new VisibilityReportScopeError(`${selection.scope} scope "${key}" is not in this frozen definition.`);
22994
+ if (!scoped) throw new VisibilityReportScopeError(`${selection.scope} scope "${key}" is not in this frozen definition.`, { reason: VisibilityReportScopeErrorReasons["retired-scope"], kind: selection.scope, key });
22844
22995
  option = scoped;
22845
22996
  if (selection.scope === "property") {
22846
22997
  edgeIds = new Set(definition.edges.filter((edge) => edge.targetKey === key).map((edge) => edge.id));
22847
22998
  } else if (selection.scope === "group") {
22848
22999
  const group = definition.groups.find((candidate) => candidate.id === key);
22849
- if (!group) throw new VisibilityReportScopeError(`Group "${key}" is not in this frozen definition.`);
23000
+ if (!group) throw new VisibilityReportScopeError(`Group "${key}" is not in this frozen definition.`, { reason: VisibilityReportScopeErrorReasons["retired-scope"], kind: "group", key });
22850
23001
  const targetKeys = new Set(group.targetKeys);
22851
23002
  edgeIds = new Set(definition.edges.filter((edge) => targetKeys.has(edge.targetKey)).map((edge) => edge.id));
22852
23003
  } else {
@@ -22859,7 +23010,7 @@ function scopeResolution(definition, selection) {
22859
23010
  const marketKey = selection.scope === "market" ? selection.scopeKey : selection.marketKey;
22860
23011
  if (marketKey === void 0) return { option, edgeIds };
22861
23012
  const market = definition.scopeOptions.find((candidate) => candidate.kind === "market" && candidate.id === marketKey);
22862
- if (!market) throw new VisibilityReportScopeError(`Market "${marketKey}" is not in this frozen definition.`);
23013
+ if (!market) throw new VisibilityReportScopeError(`Market "${marketKey}" is not in this frozen definition.`, { reason: VisibilityReportScopeErrorReasons["retired-market"], kind: "market", key: marketKey });
22863
23014
  return {
22864
23015
  option,
22865
23016
  market,
@@ -22873,7 +23024,7 @@ function scopeTargetKeys(definition, selection) {
22873
23024
  if (selection.scope === "property") return [resolution.option.id];
22874
23025
  if (selection.scope === "group") {
22875
23026
  const group = definition.groups.find((candidate) => candidate.id === resolution.option.id);
22876
- if (!group) throw new VisibilityReportScopeError(`Group "${resolution.option.id}" is not in this frozen definition.`);
23027
+ if (!group) throw new VisibilityReportScopeError(`Group "${resolution.option.id}" is not in this frozen definition.`, { reason: VisibilityReportScopeErrorReasons["retired-scope"], kind: "group", key: resolution.option.id });
22877
23028
  return [...group.targetKeys].sort(compareText14);
22878
23029
  }
22879
23030
  return [...new Set(definition.edges.filter((edge) => resolution.edgeIds.has(edge.id)).map((edge) => edge.targetKey))].sort(compareText14);
@@ -23254,17 +23405,69 @@ function filterOptions(definition, selection, run) {
23254
23405
  }
23255
23406
  return { providers, models, locations };
23256
23407
  }
23257
- function continuity(previous, current) {
23258
- if (!previous) return { state: "first", comparedRunId: null };
23408
+ function pairContinuity(previous, current) {
23259
23409
  const currentLegacy = current.definition.provenance.kind === "legacy-simple";
23260
23410
  const previousLegacy = previous.definition.provenance.kind === "legacy-simple";
23261
- if (currentLegacy || previousLegacy) return { state: "legacy-unknown", comparedRunId: previous.id };
23411
+ if (currentLegacy || previousLegacy) return "legacy-unknown";
23262
23412
  const sameDefinition = current.definitionId !== null && previous.definitionId !== null && (current.definitionId === previous.definitionId || current.comparableDefinitionIds.includes(previous.definitionId));
23263
- if (!sameDefinition) return { state: "definition-changed", comparedRunId: previous.id };
23413
+ if (!sameDefinition) return "definition-changed";
23264
23414
  if (current.modelFingerprint === null || previous.modelFingerprint === null || current.modelFingerprint !== previous.modelFingerprint) {
23265
- return { state: "model-changed", comparedRunId: previous.id };
23415
+ return "model-changed";
23416
+ }
23417
+ return "comparable";
23418
+ }
23419
+ function continuity(previous, current) {
23420
+ if (!previous) return { state: "first", comparedRunId: null };
23421
+ return { state: pairContinuity(previous, current), comparedRunId: previous.id };
23422
+ }
23423
+ function comparedRun(run) {
23424
+ return { id: run.id, createdAt: run.createdAt, completedAt: run.completedAt };
23425
+ }
23426
+ function previousSummaries(run, selection, classes) {
23427
+ const summaries = /* @__PURE__ */ new Map();
23428
+ for (const queryClass of classes) {
23429
+ try {
23430
+ const { candidates } = candidatesFor(run, selection, queryClass);
23431
+ summaries.set(queryClass, summary(candidates, run.definition, scopeTargetKeys(run.definition, selection)));
23432
+ } catch (error) {
23433
+ if (!(error instanceof VisibilityReportScopeError)) return void 0;
23434
+ summaries.set(queryClass, null);
23435
+ }
23436
+ }
23437
+ return summaries;
23438
+ }
23439
+ function comparisonBasis(selectedRun, previous, selection, classes) {
23440
+ if (previous !== null && "unreadable" in previous) return void 0;
23441
+ if (!selectedRun) return { state: "unavailable", reason: "no-selected-run", previousRun: null };
23442
+ if (selectedRun.scoped) return { state: "unavailable", reason: "scoped-run", previousRun: null };
23443
+ if (previous === null) return { state: "unavailable", reason: "no-previous-run", previousRun: null };
23444
+ if ("incomparable" in previous) return { state: "unavailable", reason: "definition-changed", previousRun: previous.incomparable };
23445
+ const pair = pairContinuity(previous.run, selectedRun);
23446
+ if (pair !== "comparable") return { state: "unavailable", reason: pair, previousRun: comparedRun(previous.run) };
23447
+ if (selectedRun.state === "partial" || previous.run.state === "partial") {
23448
+ return { state: "unavailable", reason: "partial-run", previousRun: comparedRun(previous.run) };
23449
+ }
23450
+ const summaries = previousSummaries(previous.run, selection, classes);
23451
+ return summaries === void 0 ? void 0 : { state: "available", previous: previous.run, summaries };
23452
+ }
23453
+ function rateChange(current, previous) {
23454
+ if (current.rate === null) {
23455
+ return { state: "unavailable", reason: current.reason === "not-applicable" ? "not-applicable" : "current-unavailable" };
23266
23456
  }
23267
- return { state: "comparable", comparedRunId: previous.id };
23457
+ if (previous === null || previous.rate === null) return { state: "unavailable", reason: "previous-unavailable" };
23458
+ return { state: "available", previous, delta: current.rate - previous.rate };
23459
+ }
23460
+ function populationComparison(basis, current, selection, queryClass) {
23461
+ if (basis.state === "unavailable") return { state: "unavailable", reason: basis.reason, previousRun: basis.previousRun };
23462
+ const previous = basis.summaries.get(queryClass) ?? null;
23463
+ const currentReach = selection.scope === "property" ? unavailable4("not-applicable") : current.propertyReach;
23464
+ return {
23465
+ state: "available",
23466
+ previousRun: comparedRun(basis.previous),
23467
+ mentionCoverage: rateChange(current.mentionCoverage, previous?.mentionCoverage ?? null),
23468
+ citationCoverage: rateChange(current.citationCoverage, previous?.citationCoverage ?? null),
23469
+ propertyReach: rateChange(currentReach, previous?.propertyReach ?? null)
23470
+ };
23268
23471
  }
23269
23472
  function inTime(run, selection) {
23270
23473
  return (selection.from === void 0 || run.createdAt >= selection.from) && (selection.to === void 0 || run.createdAt <= selection.to);
@@ -23305,8 +23508,12 @@ function buildVisibilityReport(input) {
23305
23508
  }
23306
23509
  runCandidates.set(run.id, byClass);
23307
23510
  }
23511
+ const basis = input.previous === void 0 ? void 0 : comparisonBasis(selectedRun, input.previous, input.selection, classes);
23308
23512
  const populations = classes.map((queryClass) => {
23309
- if (!selectedRun) return emptyPopulation(queryClass, definition, input);
23513
+ if (!selectedRun) {
23514
+ const empty = emptyPopulation(queryClass, definition, input);
23515
+ return basis === void 0 ? empty : { ...empty, comparison: populationComparison(basis, empty.summary, input.selection, queryClass) };
23516
+ }
23310
23517
  const selected = runCandidates.get(selectedRun.id)?.get(queryClass) ?? [];
23311
23518
  const trend = runs2.map((run, index2) => {
23312
23519
  const candidates = runCandidates.get(run.id)?.get(queryClass) ?? [];
@@ -23325,10 +23532,12 @@ function buildVisibilityReport(input) {
23325
23532
  });
23326
23533
  const rows = queryRows(selected, definition, input.selection);
23327
23534
  const evidence = evidenceRows(selected, definition, input.selection);
23535
+ const selectedSummary = summary(selected, definition, populationTargetKeys);
23328
23536
  return {
23329
23537
  queryClass,
23330
- summary: summary(selected, definition, populationTargetKeys),
23538
+ summary: selectedSummary,
23331
23539
  trend,
23540
+ ...basis === void 0 ? {} : { comparison: populationComparison(basis, selectedSummary, input.selection, queryClass) },
23332
23541
  queries: page2(rows, (row) => [row.queryKey, row.provider, row.model ?? "", row.location ?? ""].join("\0"), "queries", queryClass, input, selectedRun),
23333
23542
  evidence: page2(evidence, (row) => [row.answerId, row.runId].join("\0"), "evidence", queryClass, input, selectedRun),
23334
23543
  competitorAvailability: definition.competitorAvailability,
@@ -23524,56 +23733,10 @@ function v2Definition(plan, revision, manifest, run) {
23524
23733
  location: slot.context?.label ?? null
23525
23734
  };
23526
23735
  });
23527
- const groupKeysForTarget = new Map(plan.targets.map((target) => [target.stableKey, []]));
23528
- for (const group of plan.groups) {
23529
- for (const targetKey of group.targetKeys) groupKeysForTarget.get(targetKey)?.push(group.stableKey);
23530
- }
23531
- const marketKeysForGroup = /* @__PURE__ */ new Map();
23532
- const marketKeysForTarget = new Map(plan.targets.map((target) => [target.stableKey, []]));
23533
- for (const market of plan.reportingScopes ?? []) {
23534
- if (market.groupKey !== void 0) {
23535
- const keys = marketKeysForGroup.get(market.groupKey) ?? [];
23536
- keys.push(market.stableKey);
23537
- marketKeysForGroup.set(market.groupKey, keys);
23538
- }
23539
- for (const edge of market.usageEdges) marketKeysForTarget.get(edge.targetKey)?.push(market.stableKey);
23540
- }
23541
- const scopeOptions = [
23542
- { id: "project", label: "Project", kind: "project", targetCount: plan.targets.length },
23543
- ...plan.groups.map((group) => {
23544
- const marketKeys = [...new Set(marketKeysForGroup.get(group.stableKey) ?? [])].sort();
23545
- return {
23546
- id: group.stableKey,
23547
- label: group.label,
23548
- kind: "group",
23549
- targetCount: group.targetKeys.length,
23550
- ...group.parentGroupKey === void 0 ? {} : { parentGroupIds: [group.parentGroupKey] },
23551
- ...marketKeys.length === 0 ? {} : { marketKeys }
23552
- };
23553
- }),
23554
- ...plan.reportingScopes?.map((market) => ({
23555
- id: market.stableKey,
23556
- label: market.label,
23557
- kind: "market",
23558
- targetCount: new Set(market.usageEdges.map((edge) => edge.targetKey)).size,
23559
- ...market.groupKey === void 0 ? {} : { parentGroupIds: [market.groupKey] }
23560
- })) ?? [],
23561
- ...plan.targets.map((target) => {
23562
- const marketKeys = [...new Set(marketKeysForTarget.get(target.stableKey) ?? [])].sort();
23563
- return {
23564
- id: target.stableKey,
23565
- label: target.label,
23566
- kind: "property",
23567
- targetCount: 1,
23568
- parentGroupIds: [...new Set(groupKeysForTarget.get(target.stableKey) ?? [])].sort(),
23569
- ...marketKeys.length === 0 ? {} : { marketKeys }
23570
- };
23571
- })
23572
- ];
23573
23736
  return {
23574
23737
  revision,
23575
23738
  provenance: { kind: "frozen-advanced", definitionRevision: revision },
23576
- scopeOptions,
23739
+ scopeOptions: planScopeOptions(plan, { marketLinks: true }),
23577
23740
  targets: plan.targets.map((target) => ({ id: target.stableKey, label: target.label, mentionEligible: !target.mentionNotApplicable })),
23578
23741
  groups: plan.groups.map((group) => ({ id: group.stableKey, label: group.label, targetKeys: group.targetKeys })),
23579
23742
  competitorAvailability: { state: "available" },
@@ -23623,6 +23786,7 @@ function advancedRun(run, version, plan, snapshots, comparableDefinitionIds, inc
23623
23786
  completedAt: run.finishedAt,
23624
23787
  state: run.status === RunStatuses.partial ? "partial" : "measured",
23625
23788
  probe: false,
23789
+ scoped: run.measurementScope !== null,
23626
23790
  definition: v2Definition(plan, version.revision, populated.manifest, run),
23627
23791
  definitionId: version.id,
23628
23792
  comparableDefinitionIds,
@@ -23634,7 +23798,7 @@ function simpleScope(label, mentionEligible) {
23634
23798
  return {
23635
23799
  revision: null,
23636
23800
  provenance: { kind: "legacy-simple", definitionRevision: null },
23637
- scopeOptions: [{ id: "project", label: "Project", kind: "project", targetCount: 1 }],
23801
+ scopeOptions: simpleScopeOptions(),
23638
23802
  targets: [{ id: "project", label, mentionEligible }],
23639
23803
  groups: [],
23640
23804
  competitorAvailability: { state: "unavailable", reason: "frozen-competitor-identity-missing" }
@@ -23734,6 +23898,7 @@ function frozenSimpleRun(run, definition, snapshots, includeEvidence = true) {
23734
23898
  completedAt: run.finishedAt,
23735
23899
  state: run.status === RunStatuses.partial ? "partial" : "measured",
23736
23900
  probe: false,
23901
+ scoped: run.measurementScope !== null,
23737
23902
  definition: {
23738
23903
  ...simpleScope(definition.identity.displayName, matcher.keys.size > 0),
23739
23904
  provenance: { kind: "frozen-simple", definitionRevision: null },
@@ -23772,6 +23937,7 @@ function legacySimpleRun(project, run, snapshots, includeEvidence = true) {
23772
23937
  completedAt: run.finishedAt,
23773
23938
  state: run.status === RunStatuses.partial ? "partial" : "measured",
23774
23939
  probe: false,
23940
+ scoped: run.measurementScope !== null,
23775
23941
  definition: {
23776
23942
  ...simpleScope(project.displayName, true),
23777
23943
  slots,
@@ -23874,7 +24040,7 @@ function completedVisibilityRuns(db, projectId, planless, query) {
23874
24040
  eq24(runs.projectId, projectId),
23875
24041
  eq24(runs.kind, RunKinds["answer-visibility"]),
23876
24042
  inArray9(runs.status, [RunStatuses.completed, RunStatuses.partial]),
23877
- ne3(runs.trigger, RunTriggers.probe),
24043
+ notProbeRun(),
23878
24044
  planless ? isNull4(runs.measurementPlanVersionId) : isNotNull(runs.measurementPlanVersionId),
23879
24045
  // A scoped spot check is never a whole-project sweep. It can be inspected
23880
24046
  // by its exact id, but must not win the default latest-result selection.
@@ -23884,6 +24050,27 @@ function completedVisibilityRuns(db, projectId, planless, query) {
23884
24050
  query.to === void 0 ? void 0 : lte4(runs.createdAt, query.to)
23885
24051
  )).orderBy(desc7(runs.createdAt), desc7(runs.id)).limit(VISIBILITY_REPORT_MAX_RUNS).all();
23886
24052
  }
24053
+ function previousEligibleVisibilityRun(db, projectId, planless, selected) {
24054
+ return db.select().from(runs).where(and19(
24055
+ eq24(runs.projectId, projectId),
24056
+ eq24(runs.kind, RunKinds["answer-visibility"]),
24057
+ inArray9(runs.status, [RunStatuses.completed, RunStatuses.partial]),
24058
+ notProbeRun(),
24059
+ planless ? isNull4(runs.measurementPlanVersionId) : isNotNull(runs.measurementPlanVersionId),
24060
+ isNull4(runs.measurementScope),
24061
+ or4(
24062
+ lt4(runs.createdAt, selected.createdAt),
24063
+ and19(eq24(runs.createdAt, selected.createdAt), lt4(runs.id, selected.id))
24064
+ )
24065
+ )).orderBy(desc7(runs.createdAt), desc7(runs.id)).limit(1).get();
24066
+ }
24067
+ function readPreviousSweep(read) {
24068
+ try {
24069
+ return read();
24070
+ } catch {
24071
+ return { unreadable: true };
24072
+ }
24073
+ }
23887
24074
  function parseV2Versions(rows) {
23888
24075
  const versions = /* @__PURE__ */ new Map();
23889
24076
  for (const row of rows) {
@@ -23924,7 +24111,7 @@ function pendingAssignments(activePlan, measuredPlan, measuredDefinition) {
23924
24111
  const measuredSignatures = new Set(measuredPlan.assignments.filter((assignment) => measuredEdges.has([assignment.executionNodeKey, assignment.targetKey, assignment.queryId].join("\0"))).map((assignment) => assignmentSignature(measuredPlan, assignment)));
23925
24112
  return activePlan.assignments.filter((assignment) => !measuredSignatures.has(assignmentSignature(activePlan, assignment))).length;
23926
24113
  }
23927
- function advancedReaderInput(db, projectId, active, query) {
24114
+ function advancedReaderInput(db, projectId, active, query, includeComparison) {
23928
24115
  if (active.plan.schemaVersion !== MEASUREMENT_PLAN_V2_SCHEMA_VERSION) throw new Error("Expected a v2 active plan");
23929
24116
  const allVersionRows = db.select().from(measurementPlanVersions).where(eq24(measurementPlanVersions.projectId, projectId)).all();
23930
24117
  const allVersionRowsById = new Map(allVersionRows.map((row) => [row.id, row]));
@@ -23954,7 +24141,7 @@ function advancedReaderInput(db, projectId, active, query) {
23954
24141
  const compatibleSourceCandidates = sourceCandidates.filter((candidate) => activeComparableIds.has(candidate.source.row.id));
23955
24142
  const preferredSource = query.runId === void 0 ? compatibleSourceCandidates.at(0) ?? sourceCandidates.at(0) : void 0;
23956
24143
  const selectedSource = query.runId === void 0 ? preferredSource : sourceCandidates.find((candidate) => candidate.run.id === query.runId);
23957
- const candidates = sourceCandidates.map((candidate) => {
24144
+ const materialize = (candidate, includeEvidence) => {
23958
24145
  const usePresentation = presentationComparableIds.has(candidate.source.row.id);
23959
24146
  const version = usePresentation ? presentationVersion : candidate.source.row;
23960
24147
  const plan = usePresentation ? presentationPlan : candidate.source.plan;
@@ -23965,10 +24152,25 @@ function advancedReaderInput(db, projectId, active, query) {
23965
24152
  plan,
23966
24153
  snapshots,
23967
24154
  usePresentation ? [...presentationComparableIds] : comparableVersionIds(allVersionRowsById, candidate.source.row.id),
23968
- candidate.run.id === selectedSource?.run.id
24155
+ includeEvidence
23969
24156
  );
23970
- });
24157
+ };
24158
+ const candidates = sourceCandidates.map((candidate) => materialize(candidate, candidate.run.id === selectedSource?.run.id));
23971
24159
  const selectedDefinition = candidates.find((candidate) => candidate.id === selectedSource?.run.id)?.definition;
24160
+ const previousInput = () => {
24161
+ if (selectedSource === void 0 || selectedSource.run.measurementScope !== null) return null;
24162
+ return readPreviousSweep(() => {
24163
+ const predecessor = previousEligibleVisibilityRun(db, projectId, false, selectedSource.run);
24164
+ if (predecessor === void 0) return null;
24165
+ const loaded = candidates.find((candidate) => candidate.id === predecessor.id);
24166
+ if (loaded !== void 0) return { run: loaded };
24167
+ const source = predecessor.measurementPlanVersionId === null ? void 0 : versions.get(predecessor.measurementPlanVersionId);
24168
+ if (source === void 0) {
24169
+ return { incomparable: { id: predecessor.id, createdAt: predecessor.createdAt, completedAt: predecessor.finishedAt } };
24170
+ }
24171
+ return { run: materialize({ run: predecessor, source }, false) };
24172
+ });
24173
+ };
23972
24174
  return {
23973
24175
  mode: "advanced",
23974
24176
  activeRevision: active.version.revision,
@@ -23976,35 +24178,52 @@ function advancedReaderInput(db, projectId, active, query) {
23976
24178
  selection: query,
23977
24179
  activeDefinition: activeV2Definition(presentationPlan, presentationVersion.revision),
23978
24180
  ...preferredSource === void 0 ? {} : { preferredRunId: preferredSource.run.id },
23979
- runs: candidates
24181
+ runs: candidates,
24182
+ ...includeComparison ? { previous: previousInput() } : {}
23980
24183
  };
23981
24184
  }
23982
- function simpleReaderInput(db, project, query) {
23983
- const sourceRuns = completedVisibilityRuns(db, project.id, true, query);
23984
- const frozen = new Map(sourceRuns.length === 0 ? [] : db.select().from(simpleMeasurementDefinitions).where(and19(
23985
- eq24(simpleMeasurementDefinitions.projectId, project.id),
23986
- inArray9(simpleMeasurementDefinitions.runId, sourceRuns.map((run) => run.id))
24185
+ function frozenSimpleDefinitions(db, projectId, runIds) {
24186
+ if (runIds.length === 0) return /* @__PURE__ */ new Map();
24187
+ return new Map(db.select().from(simpleMeasurementDefinitions).where(and19(
24188
+ eq24(simpleMeasurementDefinitions.projectId, projectId),
24189
+ inArray9(simpleMeasurementDefinitions.runId, [...runIds])
23987
24190
  )).all().map((row) => [row.runId, row.definition]));
23988
- const selectedRunId = sourceRuns[0]?.id;
23989
- const candidates = sourceRuns.map((run) => {
23990
- const snapshots = loadVisibilitySnapshots(db, [run.id]).get(run.id) ?? [];
23991
- const definition = frozen.get(run.id);
23992
- const includeEvidence = run.id === selectedRunId;
23993
- return definition ? frozenSimpleRun(run, definition, snapshots, includeEvidence) : legacySimpleRun(project, run, snapshots, includeEvidence);
23994
- });
24191
+ }
24192
+ function simpleRunInput(db, project, run, definition, includeEvidence) {
24193
+ const snapshots = loadVisibilitySnapshots(db, [run.id]).get(run.id) ?? [];
24194
+ return definition ? frozenSimpleRun(run, definition, snapshots, includeEvidence) : legacySimpleRun(project, run, snapshots, includeEvidence);
24195
+ }
24196
+ function simpleReaderInput(db, project, query, includeComparison) {
24197
+ const sourceRuns = completedVisibilityRuns(db, project.id, true, query);
24198
+ const frozen = frozenSimpleDefinitions(db, project.id, sourceRuns.map((run) => run.id));
24199
+ const selectedRun = sourceRuns.at(0);
24200
+ const candidates = sourceRuns.map((run) => simpleRunInput(db, project, run, frozen.get(run.id), run.id === selectedRun?.id));
23995
24201
  if (query.runId !== void 0 && !candidates.some((run) => run.id === query.runId)) {
23996
24202
  throw validationError(`Measurement run "${query.runId}" is not an eligible simple result.`);
23997
24203
  }
24204
+ const previousInput = () => {
24205
+ if (selectedRun === void 0 || selectedRun.measurementScope !== null) return null;
24206
+ return readPreviousSweep(() => {
24207
+ const predecessor = previousEligibleVisibilityRun(db, project.id, true, selectedRun);
24208
+ if (predecessor === void 0) return null;
24209
+ const loaded = candidates.find((candidate) => candidate.id === predecessor.id);
24210
+ if (loaded !== void 0) return { run: loaded };
24211
+ const definition = frozenSimpleDefinitions(db, project.id, [predecessor.id]).get(predecessor.id);
24212
+ return { run: simpleRunInput(db, project, predecessor, definition, false) };
24213
+ });
24214
+ };
23998
24215
  return {
23999
24216
  mode: "simple",
24000
24217
  activeRevision: null,
24001
24218
  pendingAssignmentCount: 0,
24002
24219
  selection: query,
24003
24220
  activeDefinition: simpleActiveDefinition(project),
24004
- runs: candidates
24221
+ runs: candidates,
24222
+ ...includeComparison ? { previous: previousInput() } : {}
24005
24223
  };
24006
24224
  }
24007
- function readVisibilityReport(db, project, rawQuery) {
24225
+ function readVisibilityReport(db, project, rawQuery, options = {}) {
24226
+ const includeComparison = options.includeComparison ?? true;
24008
24227
  const query = parseQuery(rawQuery);
24009
24228
  const active = activeMeasurementPlan(db, project.id);
24010
24229
  const mode = query.mode === "auto" ? active === null ? "simple" : "advanced" : query.mode;
@@ -24012,13 +24231,12 @@ function readVisibilityReport(db, project, rawQuery) {
24012
24231
  if (mode === "advanced") {
24013
24232
  if (active === null) throw validationError("This project has no advanced measurement plan.");
24014
24233
  if (active.plan.schemaVersion !== MEASUREMENT_PLAN_V2_SCHEMA_VERSION) return unsupportedAdvancedResponse(query, active);
24015
- return buildVisibilityReport(advancedReaderInput(db, project.id, active, query));
24234
+ return buildVisibilityReport(advancedReaderInput(db, project.id, active, query, includeComparison));
24016
24235
  }
24017
- return buildVisibilityReport(simpleReaderInput(db, project, query));
24236
+ return buildVisibilityReport(simpleReaderInput(db, project, query, includeComparison));
24018
24237
  } catch (error) {
24019
- if (error instanceof VisibilityReportCursorError || error instanceof VisibilityReportScopeError) {
24020
- throw validationError(error.message);
24021
- }
24238
+ if (error instanceof VisibilityReportScopeError) throw validationError(error.message, error.details);
24239
+ if (error instanceof VisibilityReportCursorError) throw validationError(error.message);
24022
24240
  throw error;
24023
24241
  }
24024
24242
  }
@@ -24031,7 +24249,7 @@ async function visibilityReportRoutes(app) {
24031
24249
 
24032
24250
  // ../api-routes/src/query-tracking.ts
24033
24251
  import crypto20 from "crypto";
24034
- import { and as and20, desc as desc8, eq as eq25, inArray as inArray10, isNotNull as isNotNull2, isNull as isNull5, ne as ne4 } from "drizzle-orm";
24252
+ import { and as and20, desc as desc8, eq as eq25, inArray as inArray10, isNotNull as isNotNull2, isNull as isNull5, ne as ne3 } from "drizzle-orm";
24035
24253
  var SOURCE_LIMIT = 100;
24036
24254
  var MAX_REVIEW_AGE_MS = 15 * 60 * 1e3;
24037
24255
  var MAX_REVIEW_FUTURE_SKEW_MS = 60 * 1e3;
@@ -24214,7 +24432,7 @@ function simpleMeasuredAtByQuery(db, project, queryRows2, opts) {
24214
24432
  eq25(runs.projectId, project.id),
24215
24433
  eq25(runs.kind, RunKinds["answer-visibility"]),
24216
24434
  inArray10(runs.status, [RunStatuses.completed, RunStatuses.partial]),
24217
- ne4(runs.trigger, RunTriggers.probe),
24435
+ ne3(runs.trigger, RunTriggers.probe),
24218
24436
  isNull5(runs.measurementScope),
24219
24437
  isNull5(runs.measurementPlanVersionId)
24220
24438
  )).orderBy(desc8(runs.createdAt), desc8(runs.id)).limit(SOURCE_LIMIT).all();
@@ -24336,7 +24554,7 @@ function measuredAtByQuery(db, state, opts) {
24336
24554
  eq25(runs.projectId, project.id),
24337
24555
  eq25(runs.kind, RunKinds["answer-visibility"]),
24338
24556
  inArray10(runs.status, [RunStatuses.completed, RunStatuses.partial]),
24339
- ne4(runs.trigger, RunTriggers.probe),
24557
+ ne3(runs.trigger, RunTriggers.probe),
24340
24558
  isNull5(runs.measurementScope),
24341
24559
  isNotNull2(runs.measurementPlanVersionId)
24342
24560
  )).orderBy(desc8(runs.createdAt), desc8(runs.id)).limit(SOURCE_LIMIT).all();
@@ -24553,6 +24771,8 @@ function workspaceDto(db, state, opts) {
24553
24771
  ...group.parentGroupKey ? { parentGroupKey: group.parentGroupKey } : {}
24554
24772
  })) ?? [],
24555
24773
  markets: plan?.reportingScopes?.map((scope) => ({ stableKey: scope.stableKey, label: scope.label, usageEdges: scope.usageEdges, ...scope.groupKey ? { groupKey: scope.groupKey } : {} })) ?? [],
24774
+ // Tracked assignments have no market intersection, so Groups and Properties carry no market links.
24775
+ scopeOptions: plan ? planScopeOptions(plan, { marketLinks: false }) : simpleScopeOptions(),
24556
24776
  tracked: trackedRows(db, state, rows, plan, opts),
24557
24777
  savedSources: savedSources(db, state.project.id)
24558
24778
  });
@@ -26184,7 +26404,7 @@ function normalizeCompetitorList2(domains) {
26184
26404
  }
26185
26405
 
26186
26406
  // ../api-routes/src/history.ts
26187
- import { and as and22, asc as asc3, count, desc as desc9, eq as eq27, gte as gte5, inArray as inArray11, like, ne as ne5, or as or4 } from "drizzle-orm";
26407
+ import { and as and22, asc as asc3, count, desc as desc9, eq as eq27, gte as gte5, inArray as inArray11, like, ne as ne4, or as or5 } from "drizzle-orm";
26188
26408
 
26189
26409
  // ../api-routes/src/notification-redaction.ts
26190
26410
  var REDACTED_URL = {
@@ -26263,7 +26483,7 @@ async function historyRoutes(app) {
26263
26483
  app.get("/projects/:name/history", async (request, reply) => {
26264
26484
  const project = resolveProject(app.db, request.params.name);
26265
26485
  const filters = [eq27(auditLog.projectId, project.id)];
26266
- if (request.operatorAccess !== true) filters.push(ne5(auditLog.entityType, "telemetry"));
26486
+ if (request.operatorAccess !== true) filters.push(ne4(auditLog.entityType, "telemetry"));
26267
26487
  addAuditHistoryFilters(filters, request.query);
26268
26488
  const rows = app.db.select().from(auditLog).where(and22(...filters)).orderBy(desc9(auditLog.createdAt), desc9(auditLog.id)).limit(parseBoundedInt(request.query.limit, 100, 500)).offset(parseBoundedInt(request.query.offset, 0, Number.MAX_SAFE_INTEGER)).all();
26269
26489
  return reply.send(rows.map(formatAuditEntry));
@@ -26271,7 +26491,7 @@ async function historyRoutes(app) {
26271
26491
  app.get("/history", async (request, reply) => {
26272
26492
  const scopedProjectId = request.apiKey?.projectId;
26273
26493
  const filters = scopedProjectId ? [eq27(auditLog.projectId, scopedProjectId)] : [];
26274
- if (request.operatorAccess !== true) filters.push(ne5(auditLog.entityType, "telemetry"));
26494
+ if (request.operatorAccess !== true) filters.push(ne4(auditLog.entityType, "telemetry"));
26275
26495
  addAuditHistoryFilters(filters, request.query);
26276
26496
  const rows = app.db.select().from(auditLog).where(filters.length > 0 ? and22(...filters) : void 0).orderBy(desc9(auditLog.createdAt), desc9(auditLog.id)).limit(parseBoundedInt(request.query.limit, 100, 500)).offset(parseBoundedInt(request.query.offset, 0, Number.MAX_SAFE_INTEGER)).all();
26277
26497
  return reply.send(rows.map(formatAuditEntry));
@@ -26572,7 +26792,7 @@ function formatAuditEntry(row) {
26572
26792
  function addAuditHistoryFilters(filters, query) {
26573
26793
  if (query.since && !Number.isNaN(Date.parse(query.since))) filters.push(gte5(auditLog.createdAt, query.since));
26574
26794
  if (query.action) filters.push(eq27(auditLog.action, query.action));
26575
- if (query.actor === "api") filters.push(or4(
26795
+ if (query.actor === "api") filters.push(or5(
26576
26796
  eq27(auditLog.actor, "api"),
26577
26797
  like(auditLog.actor, "api-key:%"),
26578
26798
  like(auditLog.actor, "user:%")
@@ -26593,7 +26813,7 @@ function parseOptionalPositiveInt(value, max2) {
26593
26813
  }
26594
26814
 
26595
26815
  // ../api-routes/src/analytics.ts
26596
- import { and as and23, desc as desc10, eq as eq28, gte as gte6, inArray as inArray12, lt as lt4 } from "drizzle-orm";
26816
+ import { and as and23, desc as desc10, eq as eq28, gte as gte6, inArray as inArray12, lt as lt5 } from "drizzle-orm";
26597
26817
 
26598
26818
  // ../api-routes/src/analytics-model-attribution.ts
26599
26819
  function logicalSweeps(observations) {
@@ -26785,7 +27005,7 @@ async function analyticsRoutes(app) {
26785
27005
  eq28(runs.kind, RunKinds["answer-visibility"]),
26786
27006
  inArray12(runs.status, [RunStatuses.completed, RunStatuses.partial]),
26787
27007
  notProbeRun(),
26788
- lt4(runs.createdAt, cutoff)
27008
+ lt5(runs.createdAt, cutoff)
26789
27009
  );
26790
27010
  const anchorSweepTimes = app.db.selectDistinct({ createdAt: runs.createdAt }).from(runs).where(anchorRunPredicate).orderBy(desc10(runs.createdAt)).limit(ANCHOR_SWEEP_SCAN_LIMIT + 1).all().map((row) => row.createdAt);
26791
27011
  const anchorScanTruncated = anchorSweepTimes.length > ANCHOR_SWEEP_SCAN_LIMIT;
@@ -27619,7 +27839,7 @@ function nonSubresourceReferralPathCondition() {
27619
27839
  }
27620
27840
 
27621
27841
  // ../api-routes/src/report.ts
27622
- import { and as and29, desc as desc14, eq as eq33, gte as gte8, inArray as inArray15, lt as lt5, lte as lte6, ne as ne6, or as or5, sql as sql11 } from "drizzle-orm";
27842
+ import { and as and29, desc as desc14, eq as eq33, gte as gte8, inArray as inArray15, lt as lt6, lte as lte6, ne as ne5, or as or6, sql as sql11 } from "drizzle-orm";
27623
27843
 
27624
27844
  // ../api-routes/src/content.ts
27625
27845
  import crypto23 from "crypto";
@@ -28590,141 +28810,22 @@ function escapeHtml2(value) {
28590
28810
  return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
28591
28811
  }
28592
28812
  function safeHref(value) {
28593
- const trimmed = (value ?? "").trim();
28594
- if (!trimmed) return "#";
28595
- if (trimmed.startsWith("/")) return escapeHtml2(trimmed);
28596
- if (/^https?:\/\//i.test(trimmed)) return escapeHtml2(trimmed);
28597
- if (/^mailto:/i.test(trimmed)) return escapeHtml2(trimmed);
28598
- return "#";
28599
- }
28600
- function summarizeQueryParams(params) {
28601
- const keys = Array.from(params.keys());
28602
- const total = keys.length;
28603
- if (total === 0) return "";
28604
- const noun = total === 1 ? "param" : "params";
28605
- const tag = inferAdSource(params);
28606
- return tag ? `${tag} \xB7 ${total} ${noun}` : `${total} tracking ${noun}`;
28607
- }
28608
- function inferAdSource(params) {
28609
- if (params.has("fbclid")) return "Facebook Ad";
28610
- if (params.has("gclid") || params.has("gbraid") || params.has("wbraid")) return "Google Ad";
28611
- if (params.has("msclkid")) return "Microsoft Ad";
28612
- if (params.has("ttclid")) return "TikTok Ad";
28613
- if (params.has("li_fat_id")) return "LinkedIn Ad";
28614
- if (params.has("twclid")) return "X / Twitter Ad";
28615
- if (params.has("epik")) return "Pinterest Ad";
28616
- for (const k of params.keys()) {
28617
- if (k.startsWith("hsa_")) return "Search Ad";
28618
- }
28619
- const src = params.get("utm_source");
28620
- const med = params.get("utm_medium");
28621
- if (src && med) return `${src} / ${med}`;
28622
- if (src) return `Source: ${src}`;
28623
- if (med) return `Medium: ${med}`;
28624
- return null;
28813
+ return escapeHtml2(safeLinkHref(value));
28625
28814
  }
28626
28815
  function formatLandingPageHtml(raw) {
28627
- const value = raw ?? "";
28628
- const queryIdx = value.indexOf("?");
28629
- const path8 = queryIdx === -1 ? value : value.slice(0, queryIdx);
28630
- const query = queryIdx === -1 ? "" : value.slice(queryIdx + 1);
28631
- const pathHtml = `<span class="page-path">${escapeHtml2(path8 || "/")}</span>`;
28632
- if (!query) return pathHtml;
28633
- let summary2 = "";
28634
- try {
28635
- summary2 = summarizeQueryParams(new URLSearchParams(query));
28636
- } catch {
28637
- summary2 = "tracking params";
28638
- }
28639
- if (!summary2) return pathHtml;
28640
- return `${pathHtml}<span class="page-query" title="${escapeHtml2(value)}">${escapeHtml2(summary2)}</span>`;
28641
- }
28642
- function gscDateRange(report) {
28643
- const summary2 = report.executiveSummary.gsc;
28644
- const gsc = report.gsc;
28645
- const start = summary2?.periodStart || gsc?.periodStart || gsc?.trend[0]?.date || "";
28646
- const end = summary2?.periodEnd || gsc?.periodEnd || gsc?.trend.at(-1)?.date || "";
28647
- return formatDateRange(start, end);
28648
- }
28649
- function pluralize(count4, singular, plural = `${singular}s`) {
28650
- return count4 === 1 ? singular : plural;
28651
- }
28652
- var PROVIDER_DISPLAY_NAMES = {
28653
- gemini: "Gemini",
28654
- openai: "ChatGPT",
28655
- claude: "Claude",
28656
- perplexity: "Perplexity",
28657
- local: "Local model",
28658
- "cdp:chatgpt": "ChatGPT (browser)"
28659
- };
28660
- function providerDisplayName(name) {
28661
- return PROVIDER_DISPLAY_NAMES[name] ?? name.charAt(0).toUpperCase() + name.slice(1);
28662
- }
28663
- function clientHorizonLabel(horizon) {
28664
- switch (horizon) {
28665
- case "immediate":
28666
- return "Do now";
28667
- case "short-term":
28668
- return "This month";
28669
- case "medium-term":
28670
- return "Next quarter";
28671
- }
28672
- }
28673
- function clientConfidenceLabel(confidence) {
28674
- switch (confidence) {
28675
- case "high":
28676
- return "Strong evidence";
28677
- case "medium":
28678
- return "Some evidence";
28679
- case "low":
28680
- return "Worth trying";
28681
- }
28682
- }
28683
- function clientTrendCopy(delta) {
28684
- if (!delta) return null;
28685
- if (delta.direction === "up") {
28686
- return { text: `Up ${delta.deltaAbs.toFixed(1)} points ${compareCopy(delta)}`, tone: "positive", arrow: "\u2191" };
28687
- }
28688
- if (delta.direction === "down") {
28689
- return { text: `Down ${Math.abs(delta.deltaAbs).toFixed(1)} points ${compareCopy(delta)}`, tone: "negative", arrow: "\u2193" };
28690
- }
28691
- return { text: `Holding steady ${compareCopy(delta)}`, tone: "neutral", arrow: "\u2192" };
28692
- }
28693
- function compareCopy(delta) {
28694
- const window = delta.window ?? 1;
28695
- return window >= 2 ? `vs prior ${window} checks (avg ${delta.prior}%)` : `since last check (was ${delta.prior}%)`;
28696
- }
28697
- function compactInlineList(items, limit = 3) {
28698
- const visible = items.slice(0, limit);
28699
- const more = items.length - visible.length;
28700
- return `${visible.join(", ")}${more > 0 ? `, +${more} more` : ""}`;
28816
+ const page3 = describeLandingPage(raw);
28817
+ const pathHtml = `<span class="page-path">${escapeHtml2(page3.path)}</span>`;
28818
+ if (!page3.querySummary) return pathHtml;
28819
+ return `${pathHtml}<span class="page-query" title="${escapeHtml2(page3.raw)}">${escapeHtml2(page3.querySummary)}</span>`;
28701
28820
  }
28702
28821
  function renderProofChips(items, limit = 3) {
28703
28822
  if (items.length === 0) return "";
28704
28823
  const visible = items.slice(0, limit);
28705
28824
  const more = items.length - visible.length;
28706
28825
  const chips = visible.map((item2) => `<span class="proof-chip">${escapeHtml2(item2)}</span>`);
28707
- if (more > 0) chips.push(`<span class="proof-chip">+${more} more</span>`);
28826
+ if (more > 0) chips.push(`<span class="proof-chip">${reportMoreChipLabel(more)}</span>`);
28708
28827
  return `<div class="proof-chips">${chips.join("")}</div>`;
28709
28828
  }
28710
- function pressureTone2(label) {
28711
- if (label === "High") return "negative";
28712
- if (label === "Moderate") return "caution";
28713
- if (label === "Low") return "positive";
28714
- return "neutral";
28715
- }
28716
- function severityTone(severity) {
28717
- switch (severity) {
28718
- case "critical":
28719
- return "negative";
28720
- case "high":
28721
- return "negative";
28722
- case "medium":
28723
- return "caution";
28724
- case "low":
28725
- return "neutral";
28726
- }
28727
- }
28728
28829
  var STYLE = `
28729
28830
  :root {
28730
28831
  color-scheme: dark;
@@ -28942,6 +29043,7 @@ section.report-section .section-intro {
28942
29043
  .source-bar-pct { color: ${COLORS.textFaint}; font-size: 11px; }
28943
29044
  .driver-list { margin: 0; padding-left: 16px; font-size: 12px; color: ${COLORS.textMuted}; }
28944
29045
  .driver-list li { margin: 2px 0; }
29046
+ .table-scroll { overflow-x: auto; }
28945
29047
  table.report-table {
28946
29048
  width: 100%;
28947
29049
  border-collapse: collapse;
@@ -28964,7 +29066,7 @@ table.report-table th {
28964
29066
  }
28965
29067
  table.report-table td.numeric { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
28966
29068
  table.report-table td.page-cell { max-width: 0; }
28967
- table.insights-table { table-layout: fixed; }
29069
+ table.insights-table { table-layout: fixed; min-width: 680px; }
28968
29070
  table.insights-table th.col-severity, table.insights-table td.col-severity { width: 96px; }
28969
29071
  table.insights-table th.col-query, table.insights-table td.col-query { width: 18%; }
28970
29072
  table.insights-table th.col-provider, table.insights-table td.col-provider { width: 88px; }
@@ -28995,6 +29097,7 @@ table.report-table td .badge {
28995
29097
  font-weight: 600;
28996
29098
  border: 1px solid;
28997
29099
  }
29100
+ table.report-table td p.muted { margin: 2px 0 0; font-size: 12px; color: ${COLORS.textMuted}; }
28998
29101
  .cell-cited { color: ${COLORS.positive}; font-weight: 600; }
28999
29102
  .cell-not-cited { color: ${COLORS.textFaint}; }
29000
29103
  .cell-pending { color: ${COLORS.textFaint}; font-style: italic; }
@@ -29020,7 +29123,7 @@ table.report-table td .badge {
29020
29123
  }
29021
29124
  .chart-grid {
29022
29125
  display: grid;
29023
- grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
29126
+ grid-template-columns: repeat(auto-fit, minmax(min(360px, 100%), 1fr));
29024
29127
  gap: 16px;
29025
29128
  }
29026
29129
  .mention-branded-block {
@@ -29374,7 +29477,7 @@ table.report-table td .badge {
29374
29477
  .client-progress-fill.tone-negative { background: ${COLORS.negative}b3; }
29375
29478
  .client-evidence-grid {
29376
29479
  display: grid;
29377
- grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
29480
+ grid-template-columns: repeat(auto-fit, minmax(min(360px, 100%), 1fr));
29378
29481
  gap: 16px;
29379
29482
  }
29380
29483
  .client-opportunity-list {
@@ -29468,6 +29571,7 @@ table.report-table td .badge {
29468
29571
  print-color-adjust: exact;
29469
29572
  }
29470
29573
  .container { max-width: none; padding: 0; }
29574
+ .table-scroll { overflow: visible; }
29471
29575
  section.report-section,
29472
29576
  .executive-hero,
29473
29577
  .headline-card,
@@ -29499,46 +29603,34 @@ function section(opts, body) {
29499
29603
  function renderEmpty(message) {
29500
29604
  return `<div class="empty-state">${escapeHtml2(message)}</div>`;
29501
29605
  }
29502
- function locationDisplay(location) {
29503
- if (!location) return "";
29504
- const place = [location.city, location.region, location.country].filter(Boolean).join(", ");
29505
- return place ? `${location.label} (${place})` : location.label;
29506
- }
29507
29606
  function renderHeaderLocationFragment(location) {
29508
- if (!location) return " \xB7 No market set";
29509
- return ` \xB7 Market: ${escapeHtml2(locationDisplay(location))}`;
29607
+ return ` \xB7 ${escapeHtml2(reportHeaderMarketLabel(location))}`;
29510
29608
  }
29511
29609
  function renderLocationCard(report) {
29512
- const location = report.meta.location;
29513
- const handling = report.meta.providerLocationHandling;
29514
- if (!location && handling.length === 0) return "";
29515
- const otherLocations = location?.otherConfiguredLabels ?? [];
29516
- const weakLocationProviders = handling.filter((h) => h.treatment === "ignored" || h.treatment === "browser-geo").map((h) => h.provider);
29517
- const marketValue = location ? locationDisplay(location) : "No market set";
29518
- const notIncluded = otherLocations.length > 0 ? compactInlineList(otherLocations, 4) : "None";
29519
- const interpretation = location ? otherLocations.length > 0 ? `${otherLocations.length} configured ${pluralize(otherLocations.length, "market")} still ${otherLocations.length === 1 ? "needs" : "need"} a matching check before cross-market recommendations.` : "Single-market report; findings can be read as the current market view." : "No geographic hint was attached to this check; read findings as default-market or national results.";
29520
- const providerCopy = handling.length > 0 ? weakLocationProviders.length > 0 ? `${weakLocationProviders.length} ${pluralize(weakLocationProviders.length, "provider")} need a closer location check.` : `${handling.length} ${pluralize(handling.length, "provider")} received the market context.` : "No provider-level location metadata is available for this report.";
29521
- const warning = weakLocationProviders.length > 0 ? `<div class="scope-warning">
29522
- <strong>Location handling needs review</strong>
29523
- ${escapeHtml2(compactInlineList(weakLocationProviders, 4))} used weak or indirect market handling. Treat provider-level differences cautiously.
29610
+ const scope = reportMarketScope(report);
29611
+ if (!scope) return "";
29612
+ const copy = REPORT_SECTION_COPY["executive-summary"].marketScope;
29613
+ const warning = scope.weakProviders !== null ? `<div class="scope-warning">
29614
+ <strong>${copy.warningTitle}</strong>
29615
+ ${escapeHtml2(scope.weakProviders)} ${copy.warningDetail}
29524
29616
  </div>` : "";
29525
29617
  return `<div class="chart-card market-scope-card">
29526
- <h3>Market Scope</h3>
29618
+ <h3>${copy.heading}</h3>
29527
29619
  <div class="market-scope-grid">
29528
29620
  <div class="scope-tile">
29529
- <div class="scope-label">Current check</div>
29530
- <div class="scope-value">${escapeHtml2(marketValue)}</div>
29531
- <div class="scope-copy">All findings below are scoped to this run.</div>
29621
+ <div class="scope-label">${copy.currentLabel}</div>
29622
+ <div class="scope-value">${escapeHtml2(scope.currentValue)}</div>
29623
+ <div class="scope-copy">${copy.currentCopy}</div>
29532
29624
  </div>
29533
29625
  <div class="scope-tile">
29534
- <div class="scope-label">Not included</div>
29535
- <div class="scope-value">${escapeHtml2(notIncluded)}</div>
29536
- <div class="scope-copy">${escapeHtml2(interpretation)}</div>
29626
+ <div class="scope-label">${copy.notIncludedLabel}</div>
29627
+ <div class="scope-value">${escapeHtml2(scope.notIncludedValue)}</div>
29628
+ <div class="scope-copy">${escapeHtml2(scope.notIncludedCopy)}</div>
29537
29629
  </div>
29538
29630
  <div class="scope-tile">
29539
- <div class="scope-label">Provider context</div>
29540
- <div class="scope-value">${handling.length > 0 ? formatNumber(handling.length) : "\u2014"}</div>
29541
- <div class="scope-copy">${escapeHtml2(providerCopy)}</div>
29631
+ <div class="scope-label">${copy.providerLabel}</div>
29632
+ <div class="scope-value">${scope.providerValue}</div>
29633
+ <div class="scope-copy">${escapeHtml2(scope.providerCopy)}</div>
29542
29634
  </div>
29543
29635
  </div>
29544
29636
  ${warning}
@@ -29546,71 +29638,65 @@ function renderLocationCard(report) {
29546
29638
  }
29547
29639
  function renderExecutiveSummary(report) {
29548
29640
  const s = report.executiveSummary;
29549
- const trendLabel = s.trend === "up" ? "\u2191 Up" : s.trend === "down" ? "\u2193 Down" : s.trend === "flat" ? "\u2192 Flat" : "\u2014";
29550
- const trendTone = s.trend === "up" ? "positive" : s.trend === "down" ? "negative" : "neutral";
29551
- const queryNoun = s.totalQueryCount === 1 ? "query" : "queries";
29552
- const citedFragment = (s.totalQueryCount ?? 0) > 0 ? `${s.citedQueryCount}/${s.totalQueryCount} ${queryNoun} cited` : "no queries";
29553
- const mentionedFragment = (s.totalQueryCount ?? 0) > 0 ? `${s.mentionedQueryCount}/${s.totalQueryCount} ${queryNoun} mentioned` : "no queries";
29554
- const headlineTitle = (s.totalQueryCount ?? 0) > 0 ? `${s.citedQueryCount} of ${s.totalQueryCount} tracked ${queryNoun} cite ${report.meta.project.displayName}` : "No AI citation data yet";
29555
- const headlineSubtitle = (s.totalQueryCount ?? 0) > 0 ? `${s.citationRate}% citation coverage and ${s.mentionRate}% mention coverage across ${s.providerCount} ${pluralize(s.providerCount, "provider")}.` : "Run a check to populate the first citation and mention baseline.";
29556
- const priorityActions = report.agencyDiagnostics.priorities.length > 0 ? report.agencyDiagnostics.priorities : report.actionPlan;
29557
- const actionCount = dedupeReportActions(report, priorityActions).length;
29641
+ const copy = REPORT_SECTION_COPY["executive-summary"];
29642
+ const headline = reportExecutiveHeadline(report);
29558
29643
  const heroHtml = `<div class="executive-hero">
29559
29644
  <div class="headline-card">
29560
29645
  <div>
29561
- <div class="hero-kicker">Latest AI visibility check</div>
29562
- <div class="hero-title">${escapeHtml2(headlineTitle)}</div>
29646
+ <div class="hero-kicker">${copy.heroKicker}</div>
29647
+ <div class="hero-title">${escapeHtml2(headline.title)}</div>
29563
29648
  </div>
29564
- <div class="hero-subtitle">${escapeHtml2(headlineSubtitle)}</div>
29649
+ <div class="hero-subtitle">${escapeHtml2(headline.subtitle)}</div>
29565
29650
  </div>
29566
29651
  <div class="hero-proof-grid">
29567
29652
  <div class="hero-proof">
29568
- <div class="mini-label">Citation trend</div>
29569
- <div class="mini-value tone-${trendTone}">${escapeHtml2(trendLabel)}</div>
29570
- <div class="mini-copy">${escapeHtml2(citedFragment)}</div>
29653
+ <div class="mini-label">${copy.proofTiles.citationTrend}</div>
29654
+ <div class="mini-value tone-${headline.trendTone}">${escapeHtml2(headline.trendLabel)}</div>
29655
+ <div class="mini-copy">${escapeHtml2(headline.citedFragment)}</div>
29571
29656
  </div>
29572
29657
  <div class="hero-proof">
29573
- <div class="mini-label">Mention coverage</div>
29658
+ <div class="mini-label">${copy.proofTiles.mentionCoverage}</div>
29574
29659
  <div class="mini-value">${s.mentionRate}%</div>
29575
- <div class="mini-copy">${escapeHtml2(mentionedFragment)}</div>
29660
+ <div class="mini-copy">${escapeHtml2(headline.mentionedFragment)}</div>
29576
29661
  </div>
29577
29662
  <div class="hero-proof">
29578
- <div class="mini-label">Prioritized actions</div>
29579
- <div class="mini-value">${formatNumber(actionCount)}</div>
29580
- <div class="mini-copy">Sorted for agency follow-up.</div>
29663
+ <div class="mini-label">${copy.proofTiles.prioritizedActions}</div>
29664
+ <div class="mini-value">${formatNumber(headline.prioritizedActionCount)}</div>
29665
+ <div class="mini-copy">${copy.prioritizedActionsCopy}</div>
29581
29666
  </div>
29582
29667
  </div>
29583
29668
  </div>`;
29584
29669
  const metrics = [
29585
29670
  {
29586
- label: "Citation rate",
29671
+ label: copy.tiles.citationRate,
29587
29672
  value: `${s.citationRate}%`,
29588
- delta: `<span class="tone-${trendTone}">${trendLabel}</span> \xB7 ${citedFragment} \xB7 ${s.providerCount} provider${s.providerCount === 1 ? "" : "s"}`
29673
+ delta: `<span class="tone-${headline.trendTone}">${headline.trendLabel}</span> \xB7 ${headline.citedFragment} \xB7 ${headline.providerCountLabel}`
29589
29674
  },
29590
29675
  {
29591
- label: "Mention rate",
29676
+ label: copy.tiles.mentionRate,
29592
29677
  value: `${s.mentionRate}%`,
29593
- delta: mentionedFragment
29678
+ delta: headline.mentionedFragment
29594
29679
  },
29595
29680
  {
29596
- label: "Queries tracked",
29681
+ label: copy.tiles.queriesTracked,
29597
29682
  value: formatNumber(s.queryCount),
29598
- delta: `${s.competitorCount} competitor${s.competitorCount === 1 ? "" : "s"} tracked`
29683
+ delta: headline.competitorCountLabel
29599
29684
  }
29600
29685
  ];
29601
- if (s.gsc) {
29602
- const dateRange = gscDateRange(report);
29686
+ if (s.gsc && headline.gscDelta !== null) {
29603
29687
  metrics.push({
29604
- label: "GSC clicks",
29688
+ label: copy.tiles.gscClicks,
29605
29689
  value: formatNumber(s.gsc.clicks),
29606
- delta: `${formatNumber(s.gsc.impressions)} imp \xB7 ${formatRatio(s.gsc.ctr)} CTR${dateRange ? ` \xB7 ${escapeHtml2(dateRange)}` : ""}`
29690
+ // Only the date range can hold markup; the counts and separators cannot,
29691
+ // so escaping the whole line matches escaping the range alone.
29692
+ delta: escapeHtml2(headline.gscDelta)
29607
29693
  });
29608
29694
  }
29609
- if (s.ga) {
29695
+ if (s.ga && headline.gaDelta !== null) {
29610
29696
  metrics.push({
29611
- label: "GA sessions",
29697
+ label: copy.tiles.gaSessions,
29612
29698
  value: formatNumber(s.ga.sessions),
29613
- delta: `${formatNumber(s.ga.users)} users \xB7 ${formatDate(s.ga.periodStart)} \u2192 ${formatDate(s.ga.periodEnd)}`
29699
+ delta: headline.gaDelta
29614
29700
  });
29615
29701
  }
29616
29702
  const metricsHtml = `<div class="metric-grid">
@@ -29628,44 +29714,38 @@ function renderExecutiveSummary(report) {
29628
29714
  const locationHtml = renderLocationCard(report);
29629
29715
  return section(
29630
29716
  {
29631
- id: "executive-summary",
29632
- eyebrow: "Section 1",
29633
- title: "Executive Summary",
29634
- intro: "Citation = source list. Mention = answer text. They are independent signals."
29717
+ id: ReportSectionIds["executive-summary"],
29718
+ eyebrow: copy.eyebrow,
29719
+ title: copy.title,
29720
+ intro: copy.intro
29635
29721
  },
29636
29722
  heroHtml + metricsHtml + findingsHtml + locationHtml
29637
29723
  );
29638
29724
  }
29639
29725
  function deltaToneClass(direction) {
29640
- if (direction === "up") return "tone-positive";
29641
- if (direction === "down") return "tone-negative";
29642
- return "";
29643
- }
29644
- function deltaArrow(direction) {
29645
- if (direction === "up") return "\u2191";
29646
- if (direction === "down") return "\u2193";
29647
- return "\u2192";
29726
+ const tone = reportDirectionTone(direction);
29727
+ return tone === "neutral" ? "" : `tone-${tone}`;
29648
29728
  }
29649
29729
  function renderRateDeltaTile(label, delta, unit) {
29650
29730
  if (!delta) {
29651
- return `<div class="metric"><div class="label">${escapeHtml2(label)}</div><div class="value">\u2014</div><div class="delta">No prior data</div></div>`;
29731
+ return `<div class="metric"><div class="label">${escapeHtml2(label)}</div><div class="value">\u2014</div><div class="delta">${REPORT_SECTION_COPY["whats-changed"].rateTileEmpty}</div></div>`;
29652
29732
  }
29653
29733
  const valueSuffix = unit === "%" ? "%" : "";
29654
- const deltaText = unit === "%" ? `${delta.deltaAbs > 0 ? "+" : ""}${delta.deltaAbs.toFixed(1)}% vs ${delta.prior}%` : formatAverageDelta(delta);
29734
+ const deltaText = reportRateDeltaCopy(delta, unit);
29655
29735
  return `<div class="metric">
29656
29736
  <div class="label">${escapeHtml2(label)}</div>
29657
- <div class="value ${deltaToneClass(delta.direction)}">${delta.current}${valueSuffix} <span style="font-size:14px;font-weight:500;">${deltaArrow(delta.direction)}</span></div>
29737
+ <div class="value ${deltaToneClass(delta.direction)}">${delta.current}${valueSuffix} <span style="font-size:14px;font-weight:500;">${reportDeltaArrow(delta.direction)}</span></div>
29658
29738
  <div class="delta">${deltaText}</div>
29659
29739
  </div>`;
29660
29740
  }
29661
29741
  function renderTrafficDeltaTile(label, delta, countLabel, comparisonWindowDays) {
29662
29742
  if (!delta) {
29663
- return `<div class="metric"><div class="label">${escapeHtml2(label)}</div><div class="value">\u2014</div><div class="delta">Not enough trend data</div></div>`;
29743
+ return `<div class="metric"><div class="label">${escapeHtml2(label)}</div><div class="value">\u2014</div><div class="delta">${REPORT_SECTION_COPY["whats-changed"].trafficTileEmpty}</div></div>`;
29664
29744
  }
29665
- const deltaText = formatWindowCountDelta(delta, countLabel, `vs prior ${comparisonWindowDays} days`);
29745
+ const deltaText = formatWindowCountDelta(delta, countLabel, reportPriorWindowLabel(comparisonWindowDays));
29666
29746
  return `<div class="metric">
29667
29747
  <div class="label">${escapeHtml2(label)}</div>
29668
- <div class="value ${deltaToneClass(delta.direction)}">${formatNumber(delta.current)} <span style="font-size:14px;font-weight:500;">${deltaArrow(delta.direction)}</span></div>
29748
+ <div class="value ${deltaToneClass(delta.direction)}">${formatNumber(delta.current)} <span style="font-size:14px;font-weight:500;">${reportDeltaArrow(delta.direction)}</span></div>
29669
29749
  <div class="delta">${deltaText}</div>
29670
29750
  </div>`;
29671
29751
  }
@@ -29673,24 +29753,21 @@ function renderProviderMovements(movements, audience) {
29673
29753
  const meaningful = movements.filter((m) => m.direction !== "flat");
29674
29754
  if (meaningful.length === 0) return "";
29675
29755
  const isClient = audience === "client";
29756
+ const copy = isClient ? REPORT_SECTION_COPY["whats-changed"].client : REPORT_SECTION_COPY["whats-changed"].agency;
29676
29757
  const rows = meaningful.map((m) => {
29677
- const sign = m.deltaAbs > 0 ? "+" : "";
29678
29758
  return `<tr>
29679
- <td>${escapeHtml2(isClient ? providerDisplayName(m.provider) : m.provider)}</td>
29759
+ <td>${escapeHtml2(isClient ? reportProviderDisplayName(m.provider) : m.provider)}</td>
29680
29760
  <td class="numeric">${m.prior}%</td>
29681
29761
  <td class="numeric">${m.current}%</td>
29682
- <td class="numeric ${deltaToneClass(m.direction)}">${sign}${m.deltaAbs.toFixed(1)}% ${deltaArrow(m.direction)}</td>
29762
+ <td class="numeric ${deltaToneClass(m.direction)}">${reportMovementChangeCopy(m)}</td>
29683
29763
  </tr>`;
29684
29764
  }).join("");
29685
- const heading = isClient ? "How each AI tool changed" : "AI engine movements";
29686
- const colA = isClient ? "AI tool" : "Engine";
29687
- const colB = isClient ? "Was" : "Prior";
29688
- const colC = isClient ? "Now" : "Current";
29689
- return `<div class="chart-card"><h3>${heading}</h3>
29690
- <table class="report-table">
29691
- <thead><tr><th>${colA}</th><th class="numeric">${colB}</th><th class="numeric">${colC}</th><th class="numeric">Change</th></tr></thead>
29765
+ const [engineHeader, priorHeader, currentHeader, changeHeader] = copy.movementHeaders;
29766
+ return `<div class="chart-card"><h3>${copy.movementsHeading}</h3>
29767
+ <div class="table-scroll"><table class="report-table">
29768
+ <thead><tr><th>${engineHeader}</th><th class="numeric">${priorHeader}</th><th class="numeric">${currentHeader}</th><th class="numeric">${changeHeader}</th></tr></thead>
29692
29769
  <tbody>${rows}</tbody>
29693
- </table>
29770
+ </table></div>
29694
29771
  </div>`;
29695
29772
  }
29696
29773
  function renderWinsLosses(insights2, heading, emptyMessage, audience) {
@@ -29701,52 +29778,45 @@ function renderWinsLosses(insights2, heading, emptyMessage, audience) {
29701
29778
  }
29702
29779
  const isClient = audience === "client";
29703
29780
  const rows = insights2.map((i) => {
29704
- const tone = severityTone(i.severity);
29705
- const countChip = i.instanceCount > 1 ? ` <span class="badge tone-neutral">\xD7 ${i.instanceCount}</span>` : "";
29781
+ const tone = reportInsightTone(i);
29782
+ const countChip = i.instanceCount > 1 ? ` <span class="badge tone-neutral">${reportInstanceCountLabel(i.instanceCount)}</span>` : "";
29706
29783
  const severityCell = isClient ? "" : `<td><span class="badge tone-${tone}">${escapeHtml2(reportSeverityLabel(i.severity))}</span></td>`;
29707
29784
  return `<tr>
29708
29785
  ${severityCell}
29709
29786
  <td>${escapeHtml2(i.title)}${countChip}</td>
29710
29787
  <td>${escapeHtml2(i.query)}</td>
29711
- <td>${escapeHtml2(isClient ? providerDisplayName(i.provider) : i.provider)}</td>
29788
+ <td>${escapeHtml2(isClient ? reportProviderDisplayName(i.provider) : i.provider)}</td>
29712
29789
  </tr>`;
29713
29790
  }).join("");
29714
- const headers = isClient ? `<tr><th>What changed</th><th>Customer query</th><th>AI tool</th></tr>` : `<tr><th>Severity</th><th>Title</th><th>Query</th><th>Provider</th></tr>`;
29791
+ const headerCells = (isClient ? REPORT_SECTION_COPY["whats-changed"].client : REPORT_SECTION_COPY["whats-changed"].agency).insightHeaders;
29792
+ const headers = `<tr>${headerCells.map((header) => `<th>${header}</th>`).join("")}</tr>`;
29715
29793
  return `<div class="chart-card"><h3>${escapeHtml2(heading)}</h3>
29716
- <table class="report-table">
29794
+ <div class="table-scroll"><table class="report-table">
29717
29795
  <thead>${headers}</thead>
29718
29796
  <tbody>${rows}</tbody>
29719
- </table>
29797
+ </table></div>
29720
29798
  </div>`;
29721
29799
  }
29722
29800
  function renderWhatsChanged(report, audience) {
29723
29801
  const w = report.whatsChanged;
29724
29802
  const isClient = audience === "client";
29725
- const eyebrow = isClient ? "Since last check" : "Section 2";
29726
- const title = isClient ? "What's different since last check" : "What's Changed";
29727
- const intro = isClient ? "" : w.headline;
29803
+ const { client, agency } = REPORT_SECTION_COPY["whats-changed"];
29804
+ const copy = isClient ? client : agency;
29805
+ const heading = { id: ReportSectionIds["whats-changed"], eyebrow: copy.eyebrow, title: copy.title, intro: isClient ? "" : w.headline };
29728
29806
  if (!w.enoughHistory && !w.gscClicksDelta && !w.aiReferralsDelta && w.wins.length === 0 && w.regressions.length === 0) {
29729
- return section(
29730
- { id: "whats-changed", eyebrow, title, intro },
29731
- renderEmpty(isClient ? "No comparison yet \u2014 trends will appear after a few more checks." : "Trends will appear after a few more checks.")
29732
- );
29807
+ return section(heading, renderEmpty(copy.empty));
29733
29808
  }
29734
29809
  const rateTiles = `<div class="metric-grid">
29735
- ${renderRateDeltaTile(isClient ? "AI mentions your name" : "Citation rate", isClient ? w.mentionRate : w.citationRate, "%")}
29736
- ${renderRateDeltaTile(isClient ? "AI links to your website" : "Mention rate", isClient ? w.citationRate : w.mentionRate, "%")}
29737
- ${renderRateDeltaTile(isClient ? "Queries AI mentioned you in" : "Cited queries", isClient ? w.mentionedQueryCount : w.citedQueryCount, "count")}
29738
- ${renderTrafficDeltaTile(isClient ? "Visitors from Google" : "GSC clicks", w.gscClicksDelta, isClient ? "visits" : "clicks", w.comparisonWindowDays)}
29739
- ${renderTrafficDeltaTile(isClient ? "Visitors from AI tools" : "AI referral sessions", w.aiReferralsDelta, isClient ? "visits" : "sessions", w.comparisonWindowDays)}
29810
+ ${renderRateDeltaTile(isClient ? client.tiles.mentionRate : agency.tiles.citationRate, isClient ? w.mentionRate : w.citationRate, "%")}
29811
+ ${renderRateDeltaTile(isClient ? client.tiles.citationRate : agency.tiles.mentionRate, isClient ? w.citationRate : w.mentionRate, "%")}
29812
+ ${renderRateDeltaTile(isClient ? client.tiles.mentionedQueryCount : agency.tiles.citedQueryCount, isClient ? w.mentionedQueryCount : w.citedQueryCount, "count")}
29813
+ ${renderTrafficDeltaTile(copy.tiles.gscClicks, w.gscClicksDelta, copy.gscCountLabel, w.comparisonWindowDays)}
29814
+ ${renderTrafficDeltaTile(copy.tiles.aiReferrals, w.aiReferralsDelta, copy.aiReferralsCountLabel, w.comparisonWindowDays)}
29740
29815
  </div>`;
29741
29816
  const movements = renderProviderMovements(w.providerMovements, audience);
29742
- const winsHeading = isClient ? "What got better" : "Wins";
29743
- const lossesHeading = isClient ? "What got worse" : "Regressions";
29744
- const wins = renderWinsLosses(w.wins, winsHeading, isClient ? "No new wins this period." : "No new gains in the latest check.", audience);
29745
- const regressions = renderWinsLosses(w.regressions, lossesHeading, isClient ? "Nothing got worse this period." : "No new regressions in the latest check.", audience);
29746
- return section(
29747
- { id: "whats-changed", eyebrow, title, intro },
29748
- `${rateTiles}${movements}${wins}${regressions}`
29749
- );
29817
+ const wins = renderWinsLosses(w.wins, copy.winsHeading, copy.winsEmpty, audience);
29818
+ const regressions = renderWinsLosses(w.regressions, copy.regressionsHeading, copy.regressionsEmpty, audience);
29819
+ return section(heading, `${rateTiles}${movements}${wins}${regressions}`);
29750
29820
  }
29751
29821
  function renderProviderBars(rates) {
29752
29822
  if (rates.length === 0) return "";
@@ -29765,45 +29835,50 @@ function renderProviderBars(rates) {
29765
29835
  <text x="${labelWidth - 8}" y="${y + 16}" fill="${COLORS.textMuted}" font-size="11" text-anchor="end">${escapeHtml2(r.provider)}</text>
29766
29836
  <rect x="${labelWidth}" y="${y}" width="${barWidth}" height="${barHeight}" fill="${COLORS.border}" opacity="0.4" rx="3" />
29767
29837
  <rect x="${labelWidth}" y="${y}" width="${w}" height="${barHeight}" fill="${color}" rx="3" />
29768
- <text x="${labelWidth + w + 6}" y="${y + 16}" fill="${COLORS.text}" font-size="11">${r.citationRate}% (${r.citedCount}/${r.totalCount})</text>`;
29838
+ <text x="${labelWidth + w + 6}" y="${y + 16}" fill="${COLORS.text}" font-size="11">${reportProviderRateLabel(r)}</text>`;
29769
29839
  }).join("");
29840
+ const title = REPORT_SECTION_COPY["citation-scorecard"].providerChartTitle;
29770
29841
  return `<div class="chart-card">
29771
- <h3>Provider citation rate</h3>
29772
- <svg viewBox="0 0 ${width} ${height}" width="100%" preserveAspectRatio="xMinYMin meet" role="img" aria-label="Provider citation rate bar chart">
29842
+ <h3>${title}</h3>
29843
+ <svg viewBox="0 0 ${width} ${height}" width="100%" preserveAspectRatio="xMinYMin meet" role="img" aria-label="${reportBarChartLabel(title)}">
29773
29844
  ${bars}
29774
29845
  </svg>
29775
29846
  </div>`;
29776
29847
  }
29777
29848
  function renderCitationMatrix(scorecard) {
29849
+ const copy = REPORT_SECTION_COPY["citation-scorecard"];
29850
+ const glyphs = copy.glyphs;
29778
29851
  if (scorecard.queries.length === 0 || scorecard.providers.length === 0) {
29779
- return renderEmpty("Run a check to populate the citation matrix.");
29852
+ return renderEmpty(copy.empty);
29780
29853
  }
29781
29854
  const headers = scorecard.providers.map((p) => `<th>${escapeHtml2(p)}</th>`).join("");
29782
29855
  const rows = scorecard.queries.map((q, qi) => {
29783
29856
  const cells = scorecard.providers.map((_, pi) => {
29784
29857
  const cell = scorecard.matrix[qi]?.[pi];
29785
29858
  if (!cell) {
29786
- return '<td><span class="cell-pending">\u2014 \u2014</span></td>';
29859
+ return `<td><span class="cell-pending">${glyphs.missingCell}</span></td>`;
29787
29860
  }
29788
- const citedGlyph = cell.citationState === CitationStates.cited ? '<span class="cell-cited">C</span>' : '<span class="cell-not-cited">c</span>';
29789
- const mentionedGlyph = cell.answerMentioned === true ? '<span class="cell-cited">M</span>' : cell.answerMentioned === false ? '<span class="cell-not-cited">m</span>' : '<span class="cell-pending">\u2013</span>';
29861
+ const citedGlyph = cell.citationState === CitationStates.cited ? `<span class="cell-cited">${glyphs.cited}</span>` : `<span class="cell-not-cited">${glyphs.notCited}</span>`;
29862
+ const mentionedGlyph = cell.answerMentioned === true ? `<span class="cell-cited">${glyphs.mentioned}</span>` : cell.answerMentioned === false ? `<span class="cell-not-cited">${glyphs.notMentioned}</span>` : `<span class="cell-pending">${glyphs.pending}</span>`;
29790
29863
  return `<td>${citedGlyph} ${mentionedGlyph}</td>`;
29791
29864
  }).join("");
29792
29865
  return `<tr><td>${escapeHtml2(q)}</td>${cells}</tr>`;
29793
29866
  }).join("");
29794
- const legend = '<p class="section-intro" style="margin-top:0;font-size:11px;">Legend: <span class="cell-cited">C</span>/<span class="cell-not-cited">c</span> = cited/not, <span class="cell-cited">M</span>/<span class="cell-not-cited">m</span> = mentioned/not, <span class="cell-pending">\u2013</span> = no data.</p>';
29795
- return `${legend}<table class="report-table">
29796
- <thead><tr><th>Query</th>${headers}</tr></thead>
29867
+ const legendCopy = copy.legend;
29868
+ const legend = `<p class="section-intro" style="margin-top:0;font-size:11px;">${legendCopy.lead} <span class="cell-cited">${glyphs.cited}</span>/<span class="cell-not-cited">${glyphs.notCited}</span> ${legendCopy.citedMeaning} <span class="cell-cited">${glyphs.mentioned}</span>/<span class="cell-not-cited">${glyphs.notMentioned}</span> ${legendCopy.mentionedMeaning} <span class="cell-pending">${glyphs.pending}</span> ${legendCopy.pendingMeaning}</p>`;
29869
+ return `${legend}<div class="table-scroll"><table class="report-table">
29870
+ <thead><tr><th>${copy.queryHeader}</th>${headers}</tr></thead>
29797
29871
  <tbody>${rows}</tbody>
29798
- </table>`;
29872
+ </table></div>`;
29799
29873
  }
29800
29874
  function renderCitationScorecard(report) {
29801
29875
  const body = `
29802
29876
  ${renderProviderBars(report.citationScorecard.providerRates)}
29803
29877
  ${renderCitationMatrix(report.citationScorecard)}
29804
29878
  `;
29879
+ const copy = REPORT_SECTION_COPY["citation-scorecard"];
29805
29880
  return section(
29806
- { id: "citation-scorecard", eyebrow: "Section 3", title: "Citation Scorecard", intro: "Per-engine citation and mention coverage from the latest check." },
29881
+ { id: ReportSectionIds["citation-scorecard"], eyebrow: copy.eyebrow, title: copy.title, intro: copy.intro },
29807
29882
  body
29808
29883
  );
29809
29884
  }
@@ -29835,19 +29910,16 @@ function renderCompetitorBars(landscape, canonical) {
29835
29910
  { label: canonical, count: landscape.projectCitationCount, isProject: true },
29836
29911
  ...landscape.competitors.map((c) => ({ label: c.domain, count: c.citationCount, isProject: false }))
29837
29912
  ];
29838
- return renderLandscapeBars(data, "Citations per domain", "Citations per domain bar chart");
29913
+ const title = REPORT_SECTION_COPY["competitor-landscape"].citationsChartTitle;
29914
+ return renderLandscapeBars(data, title, reportBarChartLabel(title));
29839
29915
  }
29840
29916
  function renderMentionBars(section2, canonical, title) {
29841
29917
  const data = [
29842
29918
  { label: canonical, count: section2.projectMentionCount, isProject: true },
29843
29919
  ...section2.competitors.map((c) => ({ label: c.domain, count: c.mentionCount, isProject: false }))
29844
29920
  ];
29845
- return renderLandscapeBars(data, title, `${title} bar chart`);
29921
+ return renderLandscapeBars(data, title, reportBarChartLabel(title));
29846
29922
  }
29847
- var MENTION_SCOPE_LABEL = {
29848
- "non-brand": "non-brand queries",
29849
- pooled: "pooled queries \xB7 classification unavailable"
29850
- };
29851
29923
  function renderReportShareOfVoice(report) {
29852
29924
  return [report.mentionLandscape.nonBrand?.shareOfVoice, report.mentionLandscape.branded?.shareOfVoice?.queryClass === "branded" ? report.mentionLandscape.branded.shareOfVoice : void 0].map((share) => share ? `<div class="chart-note"><p>${escapeHtml2(shareOfVoiceSummary(share.percent, share.queryClass, share))}</p>${share.reason ? `<p>${escapeHtml2(shareOfVoiceReason(share.reason))}</p>` : ""}</div>` : "").join("");
29853
29925
  }
@@ -29858,19 +29930,20 @@ function renderCompetitorLandscape(report) {
29858
29930
  const brandedMentions = mentionLandscape.branded;
29859
29931
  const hasBrandedMentions = (brandedMentions?.totalAnswerSnapshots ?? 0) > 0;
29860
29932
  const noMentionData = mentionLandscape.competitors.length === 0 && mentionLandscape.projectMentionCount === 0 && !hasBrandedMentions;
29933
+ const copy = REPORT_SECTION_COPY["competitor-landscape"];
29861
29934
  if (noCitationData && noMentionData) {
29862
29935
  return section(
29863
- { id: "competitor-landscape", eyebrow: "Section 4", title: "Competitor Landscape" },
29864
- renderEmpty("No competitor data yet. Add competitors and run a check.")
29936
+ { id: ReportSectionIds["competitor-landscape"], eyebrow: copy.eyebrow, title: copy.title },
29937
+ renderEmpty(copy.empty)
29865
29938
  );
29866
29939
  }
29867
29940
  const mentionByDomain = new Map(mentionLandscape.competitors.map((m) => [m.domain, m]));
29868
29941
  const rows = competitors2.map((c) => {
29869
- const tone = pressureTone2(c.pressureLabel);
29942
+ const tone = reportPressureTone(c.pressureLabel);
29870
29943
  const mention = mentionByDomain.get(c.domain);
29871
29944
  const mentionCount = mention?.mentionCount ?? 0;
29872
29945
  const mentionTotal = mention?.totalCount ?? mentionLandscape.totalAnswerSnapshots;
29873
- const pagesDisclosure = c.theirCitedPages.length > 0 ? `<details class="cited-pages"><summary>${c.theirCitedPages.length} cited URL${c.theirCitedPages.length > 1 ? "s" : ""}</summary>
29946
+ const pagesDisclosure = c.theirCitedPages.length > 0 ? `<details class="cited-pages"><summary>${reportCitedUrlCount(c.theirCitedPages.length)}</summary>
29874
29947
  <ul>${c.theirCitedPages.map((p) => `<li><a href="${safeHref(p.url)}" target="_blank" rel="noopener noreferrer">${escapeHtml2(p.url)}</a> <span class="cited-for">${escapeHtml2(p.citedFor.join(", "))}</span></li>`).join("")}</ul>
29875
29948
  </details>` : "";
29876
29949
  return `<tr>
@@ -29879,52 +29952,39 @@ function renderCompetitorLandscape(report) {
29879
29952
  <td class="numeric">${c.citationCount} / ${c.totalCount}</td>
29880
29953
  <td class="numeric">${mentionCount} / ${mentionTotal}</td>
29881
29954
  <td class="numeric">${c.sharePct}%</td>
29882
- <td>${escapeHtml2(c.citedQueries.slice(0, 5).join(", "))}${c.citedQueries.length > 5 ? "\u2026" : ""}${pagesDisclosure}</td>
29955
+ <td>${escapeHtml2(reportTruncatedList(c.citedQueries, 5))}${pagesDisclosure}</td>
29883
29956
  </tr>`;
29884
29957
  }).join("");
29885
- const scopeLabel2 = MENTION_SCOPE_LABEL[mentionLandscape.scope] ?? MENTION_SCOPE_LABEL.pooled;
29886
- const mentionScopeTooltip = mentionLandscape.scope === "non-brand" ? `Mentions on ${scopeLabel2}. Branded queries are counted separately \u2014 the client is named on nearly all of them and a competitor cannot be, so pooling the two would rank the client on its own brand recall.` : `Mentions on ${scopeLabel2}. The project has no usable brand identity for a branded/non-brand split, so all tracked queries remain pooled and this is not a competitive category read.`;
29887
- const table = competitors2.length > 0 ? `<table class="report-table">
29888
- <thead><tr><th>Domain</th><th>Pressure</th><th>Citations</th><th class="numeric" title="${escapeHtml2(mentionScopeTooltip)}">Mentions (${escapeHtml2(scopeLabel2)})</th><th class="numeric" title="Citation share \u2014 % of cited-source slots that went to this competitor across tracked queries. Distinct from Mention Share.">Citation share</th><th>Cited queries</th></tr></thead>
29958
+ const mentionCopy = reportCompetitorMentionCopy(mentionLandscape);
29959
+ const headers = copy.headers;
29960
+ const table = competitors2.length > 0 ? `<div class="table-scroll"><table class="report-table">
29961
+ <thead><tr><th>${headers.domain}</th><th>${headers.pressure}</th><th>${headers.citations}</th><th class="numeric" title="${escapeHtml2(mentionCopy.mentionsTooltip)}">${escapeHtml2(mentionCopy.mentionsHeader)}</th><th class="numeric" title="${copy.citationShareTooltip}">${headers.citationShare}</th><th>${headers.citedQueries}</th></tr></thead>
29889
29962
  <tbody>${rows}</tbody>
29890
- </table>` : renderEmpty("No competitors configured.");
29963
+ </table></div>` : renderEmpty(copy.noCompetitors);
29891
29964
  const citationBars = renderCompetitorBars(report.competitorLandscape, report.meta.project.canonicalDomain);
29892
29965
  const mentionBars = renderMentionBars(
29893
29966
  mentionLandscape,
29894
29967
  report.meta.project.canonicalDomain,
29895
- `Mentions per domain \xB7 ${scopeLabel2}`
29968
+ mentionCopy.mentionsChartTitle
29896
29969
  );
29897
29970
  const charts = citationBars && mentionBars ? `<div class="chart-grid">${citationBars}${mentionBars}</div>` : `${citationBars}${mentionBars}`;
29898
29971
  const mentionShareUnavailable = !mentionLandscape.nonBrand?.shareOfVoice && mentionLandscape.projectMentionCount === 0 && mentionLandscape.competitors.length > 0 && mentionLandscape.competitors.every((row) => row.sharePct === null);
29899
- const mentionShareNote = mentionShareUnavailable ? `<p class="chart-note">Mention share unavailable for ${escapeHtml2(scopeLabel2)}: no tracked brand was named, so the denominator is 0.</p>` : "";
29900
- const brandedBars = hasBrandedMentions && brandedMentions ? renderMentionBars(brandedMentions, report.meta.project.canonicalDomain, "Mentions per domain \xB7 branded queries") : "";
29972
+ const mentionShareNote = mentionShareUnavailable ? `<p class="chart-note">${escapeHtml2(mentionCopy.mentionShareUnavailable)}</p>` : "";
29973
+ const brandedBars = hasBrandedMentions && brandedMentions ? renderMentionBars(brandedMentions, report.meta.project.canonicalDomain, copy.brandedChartTitle) : "";
29901
29974
  const brandedBlock = brandedBars ? `<div class="mention-branded-block">
29902
- <p class="chart-note">Branded queries contain the client's own name. The client is named on nearly all of them and a competitor structurally cannot be, so these are kept out of the competitive figure above. Read them as brand recall: ${brandedMentions?.projectMentionCount ?? 0} of ${brandedMentions?.totalAnswerSnapshots ?? 0} branded answers named the client.</p>
29975
+ <p class="chart-note">${mentionCopy.brandedNote}</p>
29903
29976
  ${brandedBars}
29904
29977
  </div>` : "";
29905
29978
  return section(
29906
29979
  {
29907
- id: "competitor-landscape",
29908
- eyebrow: "Section 4",
29909
- title: "Competitor Landscape",
29910
- intro: "Who AI engines cite and mention instead of the client."
29980
+ id: ReportSectionIds["competitor-landscape"],
29981
+ eyebrow: copy.eyebrow,
29982
+ title: copy.title,
29983
+ intro: copy.intro
29911
29984
  },
29912
29985
  `${mentionShareNote}${charts}${table}${brandedBlock}`
29913
29986
  );
29914
29987
  }
29915
- var SOURCE_CATEGORY_TONE = {
29916
- competitor: "negative",
29917
- directory: "caution",
29918
- forum: "caution",
29919
- news: "neutral",
29920
- reference: "neutral",
29921
- blog: "neutral",
29922
- social: "neutral",
29923
- video: "neutral",
29924
- ecommerce: "neutral",
29925
- academic: "neutral",
29926
- other: "neutral"
29927
- };
29928
29988
  function renderCategoryBars(buckets) {
29929
29989
  if (buckets.length === 0) return "";
29930
29990
  const total = buckets.reduce((s, b) => s + b.count, 0);
@@ -29932,7 +29992,7 @@ function renderCategoryBars(buckets) {
29932
29992
  const max2 = Math.max(...buckets.map((b) => b.count), 1);
29933
29993
  const rows = buckets.map((b) => {
29934
29994
  const pct = b.count / max2 * 100;
29935
- const tone = SOURCE_CATEGORY_TONE[b.category] ?? "neutral";
29995
+ const tone = reportSourceCategoryTone(b.category);
29936
29996
  const color = tone === "negative" ? COLORS.negative : tone === "caution" ? COLORS.caution : COLORS.accent;
29937
29997
  return `
29938
29998
  <div class="source-bar-row">
@@ -29940,11 +30000,11 @@ function renderCategoryBars(buckets) {
29940
30000
  <div class="source-bar-track">
29941
30001
  <div class="source-bar-fill" style="width:${pct.toFixed(1)}%;background:${color}"></div>
29942
30002
  </div>
29943
- <div class="source-bar-value">${b.count} <span class="source-bar-pct">(${b.sharePct}%)</span></div>
30003
+ <div class="source-bar-value">${b.count} <span class="source-bar-pct">${reportSourceCategoryShareLabel(b.sharePct)}</span></div>
29944
30004
  </div>`;
29945
30005
  }).join("");
29946
30006
  return `<div class="chart-card">
29947
- <h3>By source type</h3>
30007
+ <h3>${REPORT_SECTION_COPY["ai-source-origin"].categoriesHeading}</h3>
29948
30008
  <div class="source-bars">${rows}</div>
29949
30009
  </div>`;
29950
30010
  }
@@ -29960,7 +30020,7 @@ function renderShareBars(heading, rows, countLabel) {
29960
30020
  <div class="source-bar-track">
29961
30021
  <div class="source-bar-fill" style="width:${pct.toFixed(1)}%;background:${color}"></div>
29962
30022
  </div>
29963
- <div class="source-bar-value">${formatNumber(r.count)} <span class="source-bar-pct">${escapeHtml2(countLabel)} \xB7 ${r.sharePct}%</span></div>
30023
+ <div class="source-bar-value">${formatNumber(r.count)} <span class="source-bar-pct">${escapeHtml2(reportShareBarShareLabel(countLabel, r.sharePct))}</span></div>
29964
30024
  </div>`;
29965
30025
  }).join("");
29966
30026
  return `<div class="chart-card">
@@ -29970,32 +30030,34 @@ function renderShareBars(heading, rows, countLabel) {
29970
30030
  }
29971
30031
  function renderAiSourceOrigin(report) {
29972
30032
  const origin = report.aiSourceOrigin;
30033
+ const copy = REPORT_SECTION_COPY["ai-source-origin"];
29973
30034
  if (origin.categories.length === 0 && origin.topDomains.length === 0) {
29974
30035
  return section(
29975
- { id: "ai-source-origin", eyebrow: "Section 5", title: "AI Citation Sources" },
29976
- renderEmpty("No source data yet. Run a check first.")
30036
+ { id: ReportSectionIds["ai-source-origin"], eyebrow: copy.eyebrow, title: copy.title },
30037
+ renderEmpty(copy.empty)
29977
30038
  );
29978
30039
  }
29979
- const competitorBucket = origin.categories.find((c) => c.category === "competitor");
29980
- const headlineFragment = competitorBucket ? `<p class="source-origin-headline"><strong>${competitorBucket.sharePct}%</strong> of citations went to tracked competitors (${competitorBucket.count} of ${origin.categories.reduce((s, c) => s + c.count, 0)}).</p>` : "";
30040
+ const headline = reportSourceOriginHeadline(origin.categories);
30041
+ const headlineFragment = headline ? `<p class="source-origin-headline"><strong>${headline.share}</strong> ${headline.detail}</p>` : "";
29981
30042
  const rows = origin.topDomains.map((d) => `
29982
30043
  <tr>
29983
30044
  <td>${escapeHtml2(d.domain)}</td>
29984
30045
  <td class="numeric">${d.count}</td>
29985
- <td>${d.isCompetitor ? '<span class="badge tone-negative">Tracked competitor</span>' : '<span class="badge tone-neutral">External</span>'}</td>
30046
+ <td>${d.isCompetitor ? `<span class="badge tone-negative">${copy.trackedCompetitorTag}</span>` : `<span class="badge tone-neutral">${copy.externalTag}</span>`}</td>
29986
30047
  </tr>`).join("");
29987
- const table = origin.topDomains.length > 0 ? `<div class="chart-card"><h3>Top sources</h3>
29988
- <table class="report-table">
29989
- <thead><tr><th>Domain</th><th class="numeric">Citations</th><th>Tag</th></tr></thead>
30048
+ const [domainHeader, citationsHeader, tagHeader] = copy.topSourceHeaders;
30049
+ const table = origin.topDomains.length > 0 ? `<div class="chart-card"><h3>${copy.topSourcesHeading}</h3>
30050
+ <div class="table-scroll"><table class="report-table">
30051
+ <thead><tr><th>${domainHeader}</th><th class="numeric">${citationsHeader}</th><th>${tagHeader}</th></tr></thead>
29990
30052
  <tbody>${rows}</tbody>
29991
- </table>
30053
+ </table></div>
29992
30054
  </div>` : "";
29993
30055
  return section(
29994
30056
  {
29995
- id: "ai-source-origin",
29996
- eyebrow: "Section 5",
29997
- title: "AI Citation Sources",
29998
- intro: "External domains AI engines cited most in the latest check."
30057
+ id: ReportSectionIds["ai-source-origin"],
30058
+ eyebrow: copy.eyebrow,
30059
+ title: copy.title,
30060
+ intro: copy.intro
29999
30061
  },
30000
30062
  `${headlineFragment}${table}${renderCategoryBars(origin.categories)}`
30001
30063
  );
@@ -30022,7 +30084,7 @@ function renderLineChart(points, color, title, height = 200) {
30022
30084
  }).join("");
30023
30085
  return `<div class="chart-card">
30024
30086
  <h3>${escapeHtml2(title)}</h3>
30025
- <svg viewBox="0 0 ${width} ${height}" width="100%" preserveAspectRatio="xMinYMin meet" role="img" aria-label="${escapeHtml2(title)} line chart">
30087
+ <svg viewBox="0 0 ${width} ${height}" width="100%" preserveAspectRatio="xMinYMin meet" role="img" aria-label="${escapeHtml2(reportLineChartLabel(title))}">
30026
30088
  <line x1="${padX}" y1="${padY + usableH}" x2="${padX + usableW}" y2="${padY + usableH}" stroke="${COLORS.border}" stroke-width="1" />
30027
30089
  <text x="${padX - 6}" y="${(padY + 4).toFixed(1)}" fill="${COLORS.textFaint}" font-size="9" text-anchor="end">${formatNumber(max2)}</text>
30028
30090
  <text x="${padX - 6}" y="${(padY + usableH).toFixed(1)}" fill="${COLORS.textFaint}" font-size="9" text-anchor="end">0</text>
@@ -30034,10 +30096,11 @@ function renderLineChart(points, color, title, height = 200) {
30034
30096
  }
30035
30097
  function renderGsc(report) {
30036
30098
  const gsc = report.gsc;
30099
+ const copy = REPORT_SECTION_COPY.gsc;
30037
30100
  if (!gsc) {
30038
30101
  return section(
30039
- { id: "gsc", eyebrow: "Section 6", title: "GSC Performance" },
30040
- renderEmpty("Connect Google Search Console to populate this section.")
30102
+ { id: ReportSectionIds.gsc, eyebrow: copy.eyebrow, title: copy.title },
30103
+ renderEmpty(copy.empty)
30041
30104
  );
30042
30105
  }
30043
30106
  const rows = gsc.topQueries.map((q) => `
@@ -30050,48 +30113,48 @@ function renderGsc(report) {
30050
30113
  <td><span class="badge tone-neutral">${escapeHtml2(q.category)}</span></td>
30051
30114
  </tr>`).join("");
30052
30115
  const categoryBars = renderShareBars(
30053
- "Search demand by intent",
30116
+ copy.intentHeading,
30054
30117
  gsc.categoryBreakdown.map((c, index2) => ({
30055
30118
  label: c.category,
30056
30119
  count: c.clicks,
30057
30120
  sharePct: c.sharePct,
30058
30121
  color: COLORS.series[index2 % COLORS.series.length]
30059
30122
  })),
30060
- "clicks"
30123
+ copy.intentCountLabel
30061
30124
  );
30062
30125
  const trendChart = renderLineChart(
30063
30126
  gsc.trend.map((t) => ({ x: t.date, y: t.clicks, label: t.date.slice(5) })),
30064
30127
  COLORS.accent,
30065
- "Clicks over time"
30128
+ copy.trendTitle
30066
30129
  );
30067
30130
  const crossoverBlocks = [];
30068
30131
  if (gsc.trackedButNoGsc.length > 0) {
30069
- crossoverBlocks.push(`<div class="chart-card"><h3>AEO queries without search demand</h3>
30070
- <p class="section-intro">Review whether these still belong in the tracking set.</p>
30132
+ crossoverBlocks.push(`<div class="chart-card"><h3>${copy.untrackedDemand.heading}</h3>
30133
+ <p class="section-intro">${copy.untrackedDemand.subtitle}</p>
30071
30134
  ${renderProofChips(gsc.trackedButNoGsc, 6)}
30072
30135
  </div>`);
30073
30136
  }
30074
30137
  if (gsc.gscButNotTracked.length > 0) {
30075
- crossoverBlocks.push(`<div class="chart-card"><h3>Search queries you should track</h3>
30076
- <p class="section-intro">High-impression candidates to add to AEO tracking.</p>
30138
+ crossoverBlocks.push(`<div class="chart-card"><h3>${copy.suggestedQueries.heading}</h3>
30139
+ <p class="section-intro">${copy.suggestedQueries.subtitle}</p>
30077
30140
  ${renderProofChips(gsc.gscButNotTracked, 6)}
30078
30141
  </div>`);
30079
30142
  }
30080
- const dateRange = gscDateRange(report);
30143
+ const [queryHeader, clicksHeader, impressionsHeader, ctrHeader, positionHeader, categoryHeader] = copy.topQueryHeaders;
30081
30144
  return section(
30082
- { id: "gsc", eyebrow: "Section 6", title: "GSC Performance", intro: `Search demand signals to compare against AI visibility${dateRange ? ` for ${dateRange}` : ""}.` },
30145
+ { id: ReportSectionIds.gsc, eyebrow: copy.eyebrow, title: copy.title, intro: reportGscIntro(report) },
30083
30146
  `<div class="metric-grid">
30084
- <div class="metric"><div class="label">Total clicks</div><div class="value">${formatNumber(gsc.totalClicks)}</div></div>
30085
- <div class="metric"><div class="label">Total impressions</div><div class="value">${formatNumber(gsc.totalImpressions)}</div></div>
30086
- <div class="metric"><div class="label">Avg CTR</div><div class="value">${formatRatio(gsc.ctr)}</div></div>
30087
- <div class="metric"><div class="label">Avg position</div><div class="value">${gsc.avgPosition.toFixed(1)}</div></div>
30147
+ <div class="metric"><div class="label">${copy.tiles.clicks}</div><div class="value">${formatNumber(gsc.totalClicks)}</div></div>
30148
+ <div class="metric"><div class="label">${copy.tiles.impressions}</div><div class="value">${formatNumber(gsc.totalImpressions)}</div></div>
30149
+ <div class="metric"><div class="label">${copy.tiles.ctr}</div><div class="value">${formatRatio(gsc.ctr)}</div></div>
30150
+ <div class="metric"><div class="label">${copy.tiles.position}</div><div class="value">${gsc.avgPosition.toFixed(1)}</div></div>
30088
30151
  </div>
30089
30152
  ${trendChart}
30090
- <div class="chart-card"><h3>Top queries</h3>
30091
- <table class="report-table">
30092
- <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>
30153
+ <div class="chart-card"><h3>${copy.topQueriesHeading}</h3>
30154
+ <div class="table-scroll"><table class="report-table">
30155
+ <thead><tr><th>${queryHeader}</th><th class="numeric">${clicksHeader}</th><th class="numeric">${impressionsHeader}</th><th class="numeric">${ctrHeader}</th><th class="numeric">${positionHeader}</th><th>${categoryHeader}</th></tr></thead>
30093
30156
  <tbody>${rows}</tbody>
30094
- </table>
30157
+ </table></div>
30095
30158
  </div>
30096
30159
  ${categoryBars}
30097
30160
  ${crossoverBlocks.join("\n")}`
@@ -30099,10 +30162,11 @@ function renderGsc(report) {
30099
30162
  }
30100
30163
  function renderGa(report) {
30101
30164
  const ga = report.ga;
30165
+ const copy = REPORT_SECTION_COPY.ga;
30102
30166
  if (!ga) {
30103
30167
  return section(
30104
- { id: "ga", eyebrow: "Section 7", title: "GA4 Traffic" },
30105
- renderEmpty("Connect Google Analytics 4 to populate this section.")
30168
+ { id: ReportSectionIds.ga, eyebrow: copy.eyebrow, title: copy.title },
30169
+ renderEmpty(copy.empty)
30106
30170
  );
30107
30171
  }
30108
30172
  const pageRows = ga.topLandingPages.map((p) => `
@@ -30112,48 +30176,50 @@ function renderGa(report) {
30112
30176
  <td class="numeric">${formatNumber(p.organicSessions)}</td>
30113
30177
  </tr>`).join("");
30114
30178
  const channelBars = renderShareBars(
30115
- "Channel mix",
30179
+ copy.channelsHeading,
30116
30180
  ga.channelBreakdown.map((c, index2) => ({
30117
30181
  label: c.channel,
30118
30182
  count: c.sessions,
30119
30183
  sharePct: c.sharePct,
30120
30184
  color: COLORS.series[index2 % COLORS.series.length]
30121
30185
  })),
30122
- "sessions"
30186
+ copy.channelsCountLabel
30123
30187
  );
30188
+ const [pageHeader, sessionsHeader, organicHeader] = copy.topPageHeaders;
30124
30189
  return section(
30125
- { id: "ga", eyebrow: "Section 7", title: "GA4 Traffic", intro: `Site traffic from ${formatDate(ga.periodStart)} to ${formatDate(ga.periodEnd)}.` },
30190
+ { id: ReportSectionIds.ga, eyebrow: copy.eyebrow, title: copy.title, intro: reportGaIntro(ga) },
30126
30191
  `<div class="metric-grid">
30127
- <div class="metric"><div class="label">Total sessions</div><div class="value">${formatNumber(ga.totalSessions)}</div></div>
30128
- <div class="metric"><div class="label">Total users</div><div class="value">${formatNumber(ga.totalUsers)}</div></div>
30129
- <div class="metric"><div class="label">Organic sessions</div><div class="value">${formatNumber(ga.totalOrganicSessions)}</div></div>
30192
+ <div class="metric"><div class="label">${copy.tiles.sessions}</div><div class="value">${formatNumber(ga.totalSessions)}</div></div>
30193
+ <div class="metric"><div class="label">${copy.tiles.users}</div><div class="value">${formatNumber(ga.totalUsers)}</div></div>
30194
+ <div class="metric"><div class="label">${copy.tiles.organicSessions}</div><div class="value">${formatNumber(ga.totalOrganicSessions)}</div></div>
30130
30195
  </div>
30131
- <div class="chart-card"><h3>Top landing pages</h3>
30132
- <table class="report-table">
30133
- <thead><tr><th>Page</th><th class="numeric">Sessions</th><th class="numeric">Organic</th></tr></thead>
30196
+ <div class="chart-card"><h3>${copy.topPagesHeading}</h3>
30197
+ <div class="table-scroll"><table class="report-table">
30198
+ <thead><tr><th>${pageHeader}</th><th class="numeric">${sessionsHeader}</th><th class="numeric">${organicHeader}</th></tr></thead>
30134
30199
  <tbody>${pageRows}</tbody>
30135
- </table>
30200
+ </table></div>
30136
30201
  </div>
30137
30202
  ${channelBars}`
30138
30203
  );
30139
30204
  }
30140
30205
  function renderSocial(report) {
30141
30206
  const social = report.socialReferrals;
30207
+ const copy = REPORT_SECTION_COPY["social-referrals"];
30142
30208
  if (!social) {
30143
30209
  return section(
30144
- { id: "social-referrals", eyebrow: "Section 8", title: "Social Referrals" },
30145
- renderEmpty("No social referral data yet.")
30210
+ { id: ReportSectionIds["social-referrals"], eyebrow: copy.eyebrow, title: copy.title },
30211
+ renderEmpty(copy.empty)
30146
30212
  );
30147
30213
  }
30148
30214
  const channelBars = renderShareBars(
30149
- "Social channel mix",
30215
+ copy.channelsHeading,
30150
30216
  social.channels.map((c, index2) => ({
30151
30217
  label: c.channelGroup,
30152
30218
  count: c.sessions,
30153
30219
  sharePct: c.sharePct,
30154
30220
  color: COLORS.series[index2 % COLORS.series.length]
30155
30221
  })),
30156
- "sessions"
30222
+ copy.channelsCountLabel
30157
30223
  );
30158
30224
  const campaignRows = social.topCampaigns.map((c) => `
30159
30225
  <tr>
@@ -30161,39 +30227,41 @@ function renderSocial(report) {
30161
30227
  <td>${escapeHtml2(c.medium)}</td>
30162
30228
  <td class="numeric">${formatNumber(c.sessions)}</td>
30163
30229
  </tr>`).join("");
30230
+ const [sourceHeader, mediumHeader, sessionsHeader] = copy.campaignHeaders;
30164
30231
  return section(
30165
- { id: "social-referrals", eyebrow: "Section 8", title: "Social Referrals", intro: "Social traffic split by channel and campaign." },
30232
+ { id: ReportSectionIds["social-referrals"], eyebrow: copy.eyebrow, title: copy.title, intro: copy.intro },
30166
30233
  `<div class="metric-grid">
30167
- <div class="metric"><div class="label">Total sessions</div><div class="value">${formatNumber(social.totalSessions)}</div></div>
30168
- <div class="metric"><div class="label">Organic social</div><div class="value">${formatNumber(social.organicSessions)}</div></div>
30169
- <div class="metric"><div class="label">Paid social</div><div class="value">${formatNumber(social.paidSessions)}</div></div>
30234
+ <div class="metric"><div class="label">${copy.tiles.sessions}</div><div class="value">${formatNumber(social.totalSessions)}</div></div>
30235
+ <div class="metric"><div class="label">${copy.tiles.organic}</div><div class="value">${formatNumber(social.organicSessions)}</div></div>
30236
+ <div class="metric"><div class="label">${copy.tiles.paid}</div><div class="value">${formatNumber(social.paidSessions)}</div></div>
30170
30237
  </div>
30171
30238
  ${channelBars}
30172
- <div class="chart-card"><h3>Top campaigns</h3>
30173
- <table class="report-table">
30174
- <thead><tr><th>Source</th><th>Medium</th><th class="numeric">Sessions</th></tr></thead>
30239
+ <div class="chart-card"><h3>${copy.campaignsHeading}</h3>
30240
+ <div class="table-scroll"><table class="report-table">
30241
+ <thead><tr><th>${sourceHeader}</th><th>${mediumHeader}</th><th class="numeric">${sessionsHeader}</th></tr></thead>
30175
30242
  <tbody>${campaignRows}</tbody>
30176
- </table>
30243
+ </table></div>
30177
30244
  </div>`
30178
30245
  );
30179
30246
  }
30180
30247
  function renderAiReferrals(report) {
30181
30248
  const ai = report.aiReferrals;
30249
+ const copy = REPORT_SECTION_COPY["ai-referrals"];
30182
30250
  if (!ai) {
30183
30251
  return section(
30184
- { id: "ai-referrals", eyebrow: "Section 9", title: "AI Referral Traffic" },
30185
- renderEmpty("No AI referral traffic detected yet.")
30252
+ { id: ReportSectionIds["ai-referrals"], eyebrow: copy.eyebrow, title: copy.title },
30253
+ renderEmpty(copy.empty)
30186
30254
  );
30187
30255
  }
30188
30256
  const sourceBars = renderShareBars(
30189
- "AI sessions by source",
30257
+ copy.sourcesHeading,
30190
30258
  ai.bySource.map((s, index2) => ({
30191
30259
  label: s.source,
30192
30260
  count: s.sessions,
30193
30261
  sharePct: s.sharePct,
30194
30262
  color: COLORS.series[(index2 + 2) % COLORS.series.length]
30195
30263
  })),
30196
- "sessions"
30264
+ copy.sourcesCountLabel
30197
30265
  );
30198
30266
  const pageRows = ai.topLandingPages.map((p) => `
30199
30267
  <tr>
@@ -30203,58 +30271,50 @@ function renderAiReferrals(report) {
30203
30271
  const trendChart = renderLineChart(
30204
30272
  ai.trend.map((t) => ({ x: t.date, y: t.sessions, label: t.date.slice(5) })),
30205
30273
  COLORS.series[2],
30206
- "AI referral sessions over time"
30274
+ copy.trendTitle
30207
30275
  );
30276
+ const [pageHeader, sessionsHeader] = copy.topPageHeaders;
30208
30277
  return section(
30209
- { id: "ai-referrals", eyebrow: "Section 9", title: "AI Referral Traffic", intro: "Traffic arriving from AI answer engines." },
30278
+ { id: ReportSectionIds["ai-referrals"], eyebrow: copy.eyebrow, title: copy.title, intro: copy.intro },
30210
30279
  `<div class="metric-grid">
30211
- <div class="metric"><div class="label">Total sessions</div><div class="value">${formatNumber(ai.totalSessions)}</div></div>
30280
+ <div class="metric"><div class="label">${copy.tiles.sessions}</div><div class="value">${formatNumber(ai.totalSessions)}</div></div>
30212
30281
  </div>
30213
30282
  ${trendChart}
30214
30283
  ${sourceBars}
30215
- <div class="chart-card"><h3>Top AI landing pages</h3>
30216
- <table class="report-table">
30217
- <thead><tr><th>Page</th><th class="numeric">Sessions</th></tr></thead>
30284
+ <div class="chart-card"><h3>${copy.topPagesHeading}</h3>
30285
+ <div class="table-scroll"><table class="report-table">
30286
+ <thead><tr><th>${pageHeader}</th><th class="numeric">${sessionsHeader}</th></tr></thead>
30218
30287
  <tbody>${pageRows}</tbody>
30219
- </table>
30288
+ </table></div>
30220
30289
  </div>`
30221
30290
  );
30222
30291
  }
30223
- function serverActivityHeading(audience, hasData, windowDays2) {
30224
- const isClient = audience === "client";
30225
- return {
30226
- id: "server-activity",
30227
- eyebrow: isClient ? "AI engine attention" : "Section 10",
30228
- title: "AI Visibility \u2014 Server-Side",
30229
- intro: isClient ? hasData ? `What AI engines actually do in your server logs over the last ${windowDays2} days \u2014 the other half of citations.` : "Live telemetry from your server logs." : "What AI engines actually do in your server logs \u2014 direct evidence, complementary to citations (which measure what they say)."
30230
- };
30231
- }
30232
30292
  function renderServerActivity(report, audience) {
30233
30293
  const sa = report.serverActivity;
30234
30294
  const isClient = audience === "client";
30235
30295
  const windowDays2 = report.meta.periodDays;
30236
- const windowLabel = `${windowDays2}d`;
30237
- const priorWindowLabel = `vs prior ${windowDays2} days`;
30296
+ const copy = REPORT_SECTION_COPY["server-activity"];
30297
+ const priorWindowLabel = reportPriorWindowLabel(windowDays2);
30238
30298
  if (!sa) {
30239
30299
  if (isClient) return "";
30240
30300
  return section(
30241
- serverActivityHeading("agency", false, windowDays2),
30242
- renderEmpty("Connect a server-side traffic source to surface what AI engines do directly in your server logs \u2014 distinct from GA4 click-throughs.")
30301
+ reportServerActivityHeading("agency", false, windowDays2),
30302
+ renderEmpty(copy.agency.emptyNotConnected)
30243
30303
  );
30244
30304
  }
30245
30305
  if (!sa.hasData) {
30246
30306
  return section(
30247
- serverActivityHeading(audience, false, windowDays2),
30248
- renderEmpty(isClient ? "Your server-side traffic source is connected. Numbers will appear after the next sync." : "Source connected \u2014 collecting your first data. Numbers will appear after the next sync.")
30307
+ reportServerActivityHeading(audience, false, windowDays2),
30308
+ renderEmpty(isClient ? copy.client.empty : copy.agency.empty)
30249
30309
  );
30250
30310
  }
30251
30311
  const formatDelta = (d, suffix) => {
30252
- const copy = formatDeltaCopy(d, suffix, priorWindowLabel);
30253
- if (!copy) return "";
30254
- return `<span class="tone-${deltaTone(d.deltaPct)}">${escapeHtml2(copy)}</span>`;
30312
+ const copy2 = formatDeltaCopy(d, suffix, priorWindowLabel);
30313
+ if (!copy2) return "";
30314
+ return `<span class="tone-${deltaTone(d.deltaPct)}">${escapeHtml2(copy2)}</span>`;
30255
30315
  };
30256
- const referralRedirectNote = sa.referralRedirects > 0 ? `${formatNumber(sa.referralRedirects)} blocked by redirects` : "";
30257
- const referralSubtitle = [formatDelta(sa.referralArrivals, "sessions"), escapeHtml2(sa.referralArrivalsClassSummary), referralRedirectNote].filter(Boolean).join(" \xB7 ");
30316
+ const referralRedirectNote = reportReferralRedirectNote(sa.referralRedirects);
30317
+ const referralSubtitle = [formatDelta(sa.referralArrivals, copy.countNouns.sessions), escapeHtml2(sa.referralArrivalsClassSummary), referralRedirectNote].filter(Boolean).join(" \xB7 ");
30258
30318
  if (isClient) {
30259
30319
  const crawlerRequests = {
30260
30320
  current: sa.verifiedCrawlerHits.current + sa.unverifiedCrawlerHits.current,
@@ -30264,11 +30324,11 @@ function renderServerActivity(report, audience) {
30264
30324
  sa.verifiedCrawlerHits.prior + sa.unverifiedCrawlerHits.prior
30265
30325
  )
30266
30326
  };
30267
- const crawlerTrustSummary = `${formatNumber(sa.verifiedCrawlerHits.current)} verified \xB7 ${formatNumber(sa.unverifiedCrawlerHits.current)} unverified`;
30268
- const crawlerDelta = formatDelta(crawlerRequests, "requests");
30327
+ const crawlerTrustSummary = reportCrawlerTrustSummary(sa.verifiedCrawlerHits.current, sa.unverifiedCrawlerHits.current);
30328
+ const crawlerDelta = formatDelta(crawlerRequests, copy.countNouns.requests);
30269
30329
  const crawlerSubtitle = crawlerDelta ? `${escapeHtml2(crawlerTrustSummary)} \xB7 ${crawlerDelta}` : escapeHtml2(crawlerTrustSummary);
30270
- const userFetchDelta = formatDelta(sa.aiUserFetchHits, "requests");
30271
- const userFetchSubtitle = userFetchDelta || escapeHtml2("ChatGPT-User, Perplexity-User, MistralAI-User");
30330
+ const userFetchDelta = formatDelta(sa.aiUserFetchHits, copy.countNouns.requests);
30331
+ const userFetchSubtitle = userFetchDelta || escapeHtml2(copy.client.userFetchFallback);
30272
30332
  const clientOperators = sa.byOperator.filter((o) => o.verifiedHits > 0 || o.unverifiedHits > 0 || o.userFetchHits > 0 || o.referralArrivals > 0).slice(0, 5);
30273
30333
  const clientOperatorRows = clientOperators.map((o) => `
30274
30334
  <tr>
@@ -30277,36 +30337,37 @@ function renderServerActivity(report, audience) {
30277
30337
  <td class="numeric">${formatNumber(o.userFetchHits)}</td>
30278
30338
  <td class="numeric">${formatNumber(o.referralArrivals)}</td>
30279
30339
  </tr>`).join("");
30340
+ const [toolHeader, botRequestsHeader, userFetchesHeader2, referralSessionsHeader2] = reportServerActivityClientOperatorHeaders(windowDays2);
30280
30341
  return section(
30281
- serverActivityHeading("client", true, windowDays2),
30342
+ reportServerActivityHeading("client", true, windowDays2),
30282
30343
  `<div class="metric-grid">
30283
30344
  <div class="metric">
30284
- <div class="label">AI bot requests observed</div>
30345
+ <div class="label">${copy.client.tiles.botRequests}</div>
30285
30346
  <div class="value">${formatNumber(crawlerRequests.current)}</div>
30286
30347
  <div class="subtitle">${crawlerSubtitle}</div>
30287
30348
  </div>
30288
30349
  <div class="metric">
30289
- <div class="label">AI user-fetch requests</div>
30350
+ <div class="label">${copy.client.tiles.userFetches}</div>
30290
30351
  <div class="value">${formatNumber(sa.aiUserFetchHits.current)}</div>
30291
30352
  <div class="subtitle">${userFetchSubtitle}</div>
30292
30353
  </div>
30293
30354
  <div class="metric">
30294
- <div class="label">AI referral sessions</div>
30355
+ <div class="label">${copy.client.tiles.referralSessions}</div>
30295
30356
  <div class="value">${formatNumber(sa.referralArrivals.current)}</div>
30296
30357
  <div class="subtitle">${referralSubtitle}</div>
30297
30358
  </div>
30298
30359
  </div>
30299
- ${clientOperatorRows ? `<div class="chart-card"><h3>By AI tool</h3>
30300
- <table class="report-table">
30301
- <thead><tr><th>AI tool</th><th class="numeric">Bot requests (${windowLabel})</th><th class="numeric">User fetches (${windowLabel})</th><th class="numeric">Referral sessions</th></tr></thead>
30360
+ ${clientOperatorRows ? `<div class="chart-card"><h3>${copy.client.operatorsHeading}</h3>
30361
+ <div class="table-scroll"><table class="report-table">
30362
+ <thead><tr><th>${toolHeader}</th><th class="numeric">${botRequestsHeader}</th><th class="numeric">${userFetchesHeader2}</th><th class="numeric">${referralSessionsHeader2}</th></tr></thead>
30302
30363
  <tbody>${clientOperatorRows}</tbody>
30303
- </table>
30304
- <p class="meta">Bot requests are bulk crawl (GPTBot, PerplexityBot, \u2026). User fetches are on-demand reads triggered by real users inside an AI surface (ChatGPT-User, Perplexity-User, \u2026). Verified means the request came from an IP the operator publishes as its own; unverified means the user-agent matched but the IP is not in a published range. User-fetch totals count both, since many genuine user fetches come from outside any published range.</p>
30364
+ </table></div>
30365
+ <p class="meta">${copy.client.operatorsFootnote}</p>
30305
30366
  </div>` : ""}`
30306
30367
  );
30307
30368
  }
30308
30369
  const operatorRows = sa.byOperator.map((o) => {
30309
- const deltaText = o.deltaPct === null ? "\u2014" : `${o.deltaPct > 0 ? "+" : ""}${o.deltaPct}%`;
30370
+ const deltaText = reportServerActivityOperatorDelta(o.deltaPct);
30310
30371
  const toneClass = o.deltaPct === null ? "" : `tone-${deltaTone(o.deltaPct)}`;
30311
30372
  return `
30312
30373
  <tr>
@@ -30321,7 +30382,7 @@ function renderServerActivity(report, audience) {
30321
30382
  const pathRows = sa.topCrawledPaths.map((p) => `
30322
30383
  <tr>
30323
30384
  <td class="page-cell">${formatLandingPageHtml(p.path)}</td>
30324
- <td class="numeric">${formatNumber(p.verifiedHits + (p.unverifiedHits ?? 0))}</td>
30385
+ <td class="numeric">${formatNumber(reportServerActivityPathHits(p))}</td>
30325
30386
  <td class="numeric meta">${formatNumber(p.verifiedHits)}</td>
30326
30387
  <td class="numeric">${p.distinctOperators}</td>
30327
30388
  </tr>`).join("");
@@ -30340,75 +30401,82 @@ function renderServerActivity(report, audience) {
30340
30401
  const trendChart = sa.dailyTrend.length > 0 ? renderLineChart(
30341
30402
  sa.dailyTrend.map((d) => ({ x: d.date, y: d.verifiedCrawlerHits, label: d.date.slice(5) })),
30342
30403
  COLORS.series[1],
30343
- `Verified crawler hits over time (last ${windowDays2} days)`
30404
+ reportServerActivityTrendTitle(windowDays2)
30344
30405
  ) : "";
30406
+ const agency = copy.agency;
30407
+ const tiles = reportServerActivityAgencyTiles(windowDays2);
30408
+ const [operatorHeader, verifiedHeader, unverifiedHeader, userFetchesHeader, referralSessionsHeader, deltaHeader] = reportServerActivityAgencyOperatorHeaders(windowDays2);
30409
+ const [crawledPathHeader, hitsHeader, verifiedHitsHeader, operatorCountHeader] = agency.crawledPathHeaders;
30410
+ const [productHeader, productSessionsHeader, landingPathCountHeader] = agency.referralProductHeaders;
30411
+ const [landingPathHeader, landingSessionsHeader, productCountHeader] = agency.referralLandingHeaders;
30345
30412
  return section(
30346
- serverActivityHeading("agency", true, windowDays2),
30413
+ reportServerActivityHeading("agency", true, windowDays2),
30347
30414
  `<div class="metric-grid">
30348
30415
  <div class="metric">
30349
- <div class="label">Verified crawler hits (${windowLabel})</div>
30416
+ <div class="label">${tiles.verified}</div>
30350
30417
  <div class="value">${formatNumber(sa.verifiedCrawlerHits.current)}</div>
30351
- <div class="subtitle">${formatDelta(sa.verifiedCrawlerHits, "hits")}</div>
30418
+ <div class="subtitle">${formatDelta(sa.verifiedCrawlerHits, copy.countNouns.hits)}</div>
30352
30419
  </div>
30353
30420
  <div class="metric">
30354
- <div class="label">Unverified crawler hits (${windowLabel})</div>
30421
+ <div class="label">${tiles.unverified}</div>
30355
30422
  <div class="value">${formatNumber(sa.unverifiedCrawlerHits.current)}</div>
30356
- <div class="subtitle">${formatDelta(sa.unverifiedCrawlerHits, "hits")}</div>
30423
+ <div class="subtitle">${formatDelta(sa.unverifiedCrawlerHits, copy.countNouns.hits)}</div>
30357
30424
  </div>
30358
30425
  <div class="metric">
30359
- <div class="label">AI user-fetch hits (${windowLabel})</div>
30426
+ <div class="label">${tiles.userFetches}</div>
30360
30427
  <div class="value">${formatNumber(sa.aiUserFetchHits.current)}</div>
30361
- <div class="subtitle">${formatDelta(sa.aiUserFetchHits, "hits")}</div>
30428
+ <div class="subtitle">${formatDelta(sa.aiUserFetchHits, copy.countNouns.hits)}</div>
30362
30429
  </div>
30363
30430
  <div class="metric">
30364
- <div class="label">AI-referral sessions (${windowLabel})</div>
30431
+ <div class="label">${tiles.referralSessions}</div>
30365
30432
  <div class="value">${formatNumber(sa.referralArrivals.current)}</div>
30366
30433
  <div class="subtitle">${referralSubtitle}</div>
30367
30434
  </div>
30368
30435
  </div>
30369
30436
  ${trendChart}
30370
- ${operatorRows ? `<div class="chart-card"><h3>Per AI operator</h3>
30371
- <p class="meta">Verified means the request's source IP falls inside the operator's published range. Unverified bots claim the user-agent but the IP is not in a published range, so it could be the real bot or an imitator. User fetches are on-demand reads from an AI surface on behalf of a real user (ChatGPT-User, Perplexity-User, \u2026), disjoint from bulk crawl and counted whether or not the IP can be verified.</p>
30372
- <table class="report-table">
30373
- <thead><tr><th>Operator</th><th class="numeric">Verified hits</th><th class="numeric">Unverified</th><th class="numeric">User fetches</th><th class="numeric">Referral sessions</th><th class="numeric">${windowLabel} delta</th></tr></thead>
30437
+ ${operatorRows ? `<div class="chart-card"><h3>${agency.operatorsHeading}</h3>
30438
+ <p class="meta">${agency.operatorsNote}</p>
30439
+ <div class="table-scroll"><table class="report-table">
30440
+ <thead><tr><th>${operatorHeader}</th><th class="numeric">${verifiedHeader}</th><th class="numeric">${unverifiedHeader}</th><th class="numeric">${userFetchesHeader}</th><th class="numeric">${referralSessionsHeader}</th><th class="numeric">${deltaHeader}</th></tr></thead>
30374
30441
  <tbody>${operatorRows}</tbody>
30375
- </table>
30442
+ </table></div>
30376
30443
  </div>` : ""}
30377
- ${pathRows ? `<div class="chart-card"><h3>Top crawled paths</h3>
30378
- <p class="meta">Pages AI bots fetched most often (verified only, last ${windowLabel}).</p>
30379
- <table class="report-table">
30380
- <thead><tr><th>Path</th><th class="numeric">Hits</th><th class="numeric">Verified</th><th class="numeric">Distinct operators</th></tr></thead>
30444
+ ${pathRows ? `<div class="chart-card"><h3>${agency.crawledPathsHeading}</h3>
30445
+ <p class="meta">${reportServerActivityCrawledPathsNote(windowDays2)}</p>
30446
+ <div class="table-scroll"><table class="report-table">
30447
+ <thead><tr><th>${crawledPathHeader}</th><th class="numeric">${hitsHeader}</th><th class="numeric">${verifiedHitsHeader}</th><th class="numeric">${operatorCountHeader}</th></tr></thead>
30381
30448
  <tbody>${pathRows}</tbody>
30382
- </table>
30449
+ </table></div>
30383
30450
  </div>` : ""}
30384
- ${referralProductRows ? `<div class="chart-card"><h3>AI-referral sessions by product</h3>
30385
- <p class="meta">Where humans landed coming from each AI product (chatgpt.com, claude.ai, \u2026).</p>
30386
- <table class="report-table">
30387
- <thead><tr><th>Product</th><th class="numeric">Sessions</th><th class="numeric">Distinct landing paths</th></tr></thead>
30451
+ ${referralProductRows ? `<div class="chart-card"><h3>${agency.referralProductsHeading}</h3>
30452
+ <p class="meta">${agency.referralProductsNote}</p>
30453
+ <div class="table-scroll"><table class="report-table">
30454
+ <thead><tr><th>${productHeader}</th><th class="numeric">${productSessionsHeader}</th><th class="numeric">${landingPathCountHeader}</th></tr></thead>
30388
30455
  <tbody>${referralProductRows}</tbody>
30389
- </table>
30456
+ </table></div>
30390
30457
  </div>` : ""}
30391
- ${referralLandingRows ? `<div class="chart-card"><h3>Top AI-referral landing paths</h3>
30392
- <table class="report-table">
30393
- <thead><tr><th>Path</th><th class="numeric">Sessions</th><th class="numeric">Distinct products</th></tr></thead>
30458
+ ${referralLandingRows ? `<div class="chart-card"><h3>${agency.referralLandingHeading}</h3>
30459
+ <div class="table-scroll"><table class="report-table">
30460
+ <thead><tr><th>${landingPathHeader}</th><th class="numeric">${landingSessionsHeader}</th><th class="numeric">${productCountHeader}</th></tr></thead>
30394
30461
  <tbody>${referralLandingRows}</tbody>
30395
- </table>
30462
+ </table></div>
30396
30463
  </div>` : ""}`
30397
30464
  );
30398
30465
  }
30399
30466
  function renderIndexingHealth(report) {
30400
30467
  const ih = report.indexingHealth;
30468
+ const copy = REPORT_SECTION_COPY["indexing-health"];
30401
30469
  if (!ih) {
30402
30470
  return section(
30403
- { id: "indexing-health", eyebrow: "Section 11", title: "Indexing Health" },
30404
- renderEmpty("Connect Google Search Console or Bing Webmaster Tools and run a sitemap inspection.")
30471
+ { id: ReportSectionIds["indexing-health"], eyebrow: copy.eyebrow, title: copy.title },
30472
+ renderEmpty(copy.empty)
30405
30473
  );
30406
30474
  }
30407
30475
  const segments = [
30408
- { label: "Indexed", count: ih.indexed, color: COLORS.positive },
30409
- { label: "Not indexed", count: ih.notIndexed, color: COLORS.caution },
30410
- { label: "Deindexed", count: ih.deindexed, color: COLORS.negative },
30411
- { label: "Unknown", count: ih.unknown, color: COLORS.neutral }
30476
+ { label: copy.segments.indexed, count: ih.indexed, color: COLORS.positive },
30477
+ { label: copy.segments.notIndexed, count: ih.notIndexed, color: COLORS.caution },
30478
+ { label: copy.segments.deindexed, count: ih.deindexed, color: COLORS.negative },
30479
+ { label: copy.segments.unknown, count: ih.unknown, color: COLORS.neutral }
30412
30480
  ].filter((s) => s.count > 0);
30413
30481
  const total = segments.reduce((s, x) => s + x.count, 0) || 1;
30414
30482
  const width = 600;
@@ -30420,166 +30488,171 @@ function renderIndexingHealth(report) {
30420
30488
  acc += w;
30421
30489
  return `<rect x="${x}" y="0" width="${w}" height="${height}" fill="${s.color}" />`;
30422
30490
  }).join("");
30423
- const legend = segments.map((s) => `<span><span class="legend-swatch" style="background:${s.color}"></span>${escapeHtml2(s.label)}: ${s.count}</span>`).join("");
30491
+ const legend = segments.map((s) => `<span><span class="legend-swatch" style="background:${s.color}"></span>${escapeHtml2(reportIndexingLegendLabel(s.label, s.count))}</span>`).join("");
30424
30492
  return section(
30425
- { id: "indexing-health", eyebrow: "Section 11", title: "Indexing Health", intro: `Pages absent from ${ih.provider === "google" ? "Google" : "Bing"} are harder for AI engines to retrieve.` },
30493
+ { id: ReportSectionIds["indexing-health"], eyebrow: copy.eyebrow, title: copy.title, intro: reportIndexingIntro(ih.provider) },
30426
30494
  `<div class="metric-grid">
30427
- <div class="metric"><div class="label">Indexed</div><div class="value tone-positive">${formatNumber(ih.indexed)}</div></div>
30428
- <div class="metric"><div class="label">Total inspected</div><div class="value">${formatNumber(ih.total)}</div></div>
30429
- <div class="metric"><div class="label">Indexed share</div><div class="value">${ih.indexedPct}%</div></div>
30495
+ <div class="metric"><div class="label">${copy.tiles.indexed}</div><div class="value tone-positive">${formatNumber(ih.indexed)}</div></div>
30496
+ <div class="metric"><div class="label">${copy.tiles.total}</div><div class="value">${formatNumber(ih.total)}</div></div>
30497
+ <div class="metric"><div class="label">${copy.tiles.share}</div><div class="value">${ih.indexedPct}%</div></div>
30430
30498
  </div>
30431
30499
  <div class="chart-card">
30432
- <h3>Coverage breakdown</h3>
30433
- <svg viewBox="0 0 ${width} ${height}" width="100%" preserveAspectRatio="xMinYMin meet" role="img" aria-label="Coverage stacked bar">${bars}</svg>
30500
+ <h3>${copy.coverageHeading}</h3>
30501
+ <svg viewBox="0 0 ${width} ${height}" width="100%" preserveAspectRatio="xMinYMin meet" role="img" aria-label="${copy.coverageLabel}">${bars}</svg>
30434
30502
  <div class="legend">${legend}</div>
30435
30503
  </div>`
30436
30504
  );
30437
30505
  }
30438
30506
  function renderCitationsTrend(report) {
30439
30507
  const trend = report.citationsTrend;
30508
+ const copy = REPORT_SECTION_COPY["citations-trend"];
30440
30509
  if (trend.length === 0) {
30441
30510
  return section(
30442
- { id: "citations-trend", eyebrow: "Section 12", title: "Citations Over Time" },
30443
- renderEmpty("Run multiple checks to see a trend.")
30511
+ { id: ReportSectionIds["citations-trend"], eyebrow: copy.eyebrow, title: copy.title },
30512
+ renderEmpty(copy.empty)
30444
30513
  );
30445
30514
  }
30446
30515
  if (isTrendBaseline(trend)) {
30447
30516
  return section(
30448
- { id: "citations-trend", eyebrow: "Section 12", title: "Citations Over Time" },
30449
- renderEmpty(`Building baseline (${trend.length} of ${MIN_TREND_POINTS} checks completed). Trend will appear once more checks are recorded.`)
30517
+ { id: ReportSectionIds["citations-trend"], eyebrow: copy.eyebrow, title: copy.title },
30518
+ renderEmpty(reportCitationsTrendBaseline(trend.length))
30450
30519
  );
30451
30520
  }
30452
30521
  const chart = renderLineChart(
30453
30522
  trend.map((t) => ({ x: t.date, y: t.citationRate, label: formatDate(t.date) })),
30454
30523
  COLORS.positive,
30455
- "Overall citation rate",
30524
+ copy.chartTitle,
30456
30525
  220
30457
30526
  );
30458
30527
  const rows = trend.map((t) => `
30459
30528
  <tr>
30460
30529
  <td>${formatDate(t.date)}</td>
30461
30530
  <td class="numeric">${t.citationRate}% <span class="cell-pending">(${t.citedQueryCount}/${t.totalQueryCount})</span></td>
30462
- <td>${t.providerRates.map((r) => `${escapeHtml2(r.provider)}: ${r.citationRate}%`).join(" \xB7 ")}</td>
30531
+ <td>${escapeHtml2(reportTrendProviderRates(t.providerRates))}</td>
30463
30532
  </tr>`).join("");
30533
+ const [checkHeader, citedQueriesHeader, engineRatesHeader] = copy.breakdownHeaders;
30464
30534
  return section(
30465
- { id: "citations-trend", eyebrow: "Section 12", title: "Citations Over Time", intro: "Citation coverage across recent checks." },
30535
+ { id: ReportSectionIds["citations-trend"], eyebrow: copy.eyebrow, title: copy.title, intro: copy.intro },
30466
30536
  `${chart}
30467
- <div class="chart-card"><h3>Check-by-check breakdown</h3>
30468
- <table class="report-table">
30469
- <thead><tr><th>Check</th><th class="numeric">Cited queries</th><th>Per-engine rates</th></tr></thead>
30537
+ <div class="chart-card"><h3>${copy.breakdownHeading}</h3>
30538
+ <div class="table-scroll"><table class="report-table">
30539
+ <thead><tr><th>${checkHeader}</th><th class="numeric">${citedQueriesHeader}</th><th>${engineRatesHeader}</th></tr></thead>
30470
30540
  <tbody>${rows}</tbody>
30471
- </table>
30541
+ </table></div>
30472
30542
  </div>`
30473
30543
  );
30474
30544
  }
30475
30545
  function renderInsights(report) {
30476
30546
  const list = report.insights;
30547
+ const copy = REPORT_SECTION_COPY.insights;
30477
30548
  if (list.length === 0) {
30478
30549
  return section(
30479
- { id: "insights", eyebrow: "Section 13", title: "Insights & Alerts" },
30480
- renderEmpty("No insights yet \u2014 run a check to generate alerts.")
30550
+ { id: ReportSectionIds.insights, eyebrow: copy.eyebrow, title: copy.title },
30551
+ renderEmpty(copy.empty)
30481
30552
  );
30482
30553
  }
30483
30554
  const haveDeduped = list.every((i) => typeof i.instanceCount === "number");
30484
30555
  const rows = (haveDeduped ? list.map((i) => ({ rep: i, count: i.instanceCount })) : groupInsights(list).map((g) => ({ rep: g.representative, count: g.count }))).map(({ rep: i, count: count4 }) => {
30485
- const tone = severityTone(i.severity);
30486
- const countChip = count4 > 1 ? ` <span class="badge tone-neutral">\xD7 ${count4}</span>` : "";
30556
+ const tone = reportInsightTone(i);
30557
+ const countChip = count4 > 1 ? ` <span class="badge tone-neutral">${reportInstanceCountLabel(count4)}</span>` : "";
30487
30558
  return `<tr>
30488
30559
  <td class="col-severity"><span class="badge tone-${tone}">${escapeHtml2(reportSeverityLabel(i.severity))}</span></td>
30489
30560
  <td class="col-title">${escapeHtml2(i.title)}${countChip}</td>
30490
30561
  <td class="col-query">${escapeHtml2(i.query)}</td>
30491
30562
  <td class="col-provider">${escapeHtml2(i.provider)}</td>
30492
- <td class="col-recommendation">${i.recommendation ? escapeHtml2(i.recommendation) : '<span class="cell-pending">\u2014</span>'}</td>
30563
+ <td class="col-recommendation">${i.recommendation ? escapeHtml2(i.recommendation) : `<span class="cell-pending">${copy.noRecommendation}</span>`}</td>
30493
30564
  </tr>`;
30494
30565
  }).join("");
30566
+ const [severityHeader, titleHeader, queryHeader, providerHeader, recommendationHeader] = copy.headers;
30495
30567
  return section(
30496
- { id: "insights", eyebrow: "Section 13", title: "Insights & Alerts", intro: "Regressions, gains, and recurring alerts ordered by severity." },
30497
- `<table class="report-table insights-table">
30568
+ { id: ReportSectionIds.insights, eyebrow: copy.eyebrow, title: copy.title, intro: copy.intro },
30569
+ `<div class="table-scroll"><table class="report-table insights-table">
30498
30570
  <thead><tr>
30499
- <th class="col-severity">Severity</th>
30500
- <th class="col-title">Title</th>
30501
- <th class="col-query">Query</th>
30502
- <th class="col-provider">Provider</th>
30503
- <th class="col-recommendation">Recommendation</th>
30571
+ <th class="col-severity">${severityHeader}</th>
30572
+ <th class="col-title">${titleHeader}</th>
30573
+ <th class="col-query">${queryHeader}</th>
30574
+ <th class="col-provider">${providerHeader}</th>
30575
+ <th class="col-recommendation">${recommendationHeader}</th>
30504
30576
  </tr></thead>
30505
30577
  <tbody>${rows}</tbody>
30506
- </table>`
30578
+ </table></div>`
30507
30579
  );
30508
30580
  }
30509
30581
  function renderOpportunities(report) {
30510
30582
  const opps = dedupeReportOpportunities(report);
30511
30583
  if (opps.length === 0) return "";
30512
30584
  const canonical = report.meta.project.canonicalDomain;
30585
+ const copy = REPORT_SECTION_COPY["content-opportunities"];
30513
30586
  const highlights = `<div class="opportunity-grid">
30514
30587
  ${opps.slice(0, 3).map((o) => `<article class="opportunity-card">
30515
- <div class="opportunity-score" title="Opportunity score (0\u2013100, higher = stronger)">${Math.round(o.score)}<span class="opportunity-score-suffix">/100</span></div>
30588
+ <div class="opportunity-score" title="${copy.scoreCardTooltip}">${Math.round(o.score)}<span class="opportunity-score-suffix">${copy.scoreSuffix}</span></div>
30516
30589
  <h3>${escapeHtml2(o.query)}</h3>
30517
- <p>${escapeHtml2(contentActionLabel(o.action))} \xB7 ${escapeHtml2(actionConfidenceLabel(o.actionConfidence))} confidence</p>
30590
+ <p>${escapeHtml2(reportOpportunityActionLine(o))}</p>
30518
30591
  ${renderProofChips(o.drivers, 2)}
30519
30592
  </article>`).join("")}
30520
30593
  </div>`;
30521
30594
  const rows = opps.slice(0, 10).map((o) => {
30522
- const ourPage = o.ourBestPage ? `<a href="${safeHref(absolutizeProjectUrl(o.ourBestPage.url, canonical))}" target="_blank" rel="noopener noreferrer">${escapeHtml2(o.ourBestPage.url)}</a>` : '<span class="cell-not-cited">No page yet</span>';
30523
- const winning = o.winningCompetitor ? `<a href="${safeHref(o.winningCompetitor.url)}" target="_blank" rel="noopener noreferrer">${escapeHtml2(o.winningCompetitor.domain)}</a>` : '<span class="cell-not-cited">\u2014</span>';
30524
- const drivers = o.drivers.length > 0 ? `<ul class="driver-list">${o.drivers.map((d) => `<li>${escapeHtml2(d)}</li>`).join("")}</ul>` : '<span class="cell-not-cited">No driver signal yet</span>';
30595
+ const ourPage = o.ourBestPage ? `<a href="${safeHref(absolutizeProjectUrl(o.ourBestPage.url, canonical))}" target="_blank" rel="noopener noreferrer">${escapeHtml2(o.ourBestPage.url)}</a>` : `<span class="cell-not-cited">${copy.noPage}</span>`;
30596
+ const winning = o.winningCompetitor ? `<a href="${safeHref(o.winningCompetitor.url)}" target="_blank" rel="noopener noreferrer">${escapeHtml2(o.winningCompetitor.domain)}</a>` : `<span class="cell-not-cited">${copy.noWinningCompetitor}</span>`;
30597
+ const drivers = o.drivers.length > 0 ? `<ul class="driver-list">${o.drivers.map((d) => `<li>${escapeHtml2(d)}</li>`).join("")}</ul>` : `<span class="cell-not-cited">${copy.noDriverSignal}</span>`;
30525
30598
  const surfaceTone = o.winnabilityClass === "ceded" ? "tone-caution" : "tone-neutral";
30526
30599
  return `<tr>
30527
30600
  <td>${escapeHtml2(o.query)}</td>
30528
30601
  <td><span class="badge tone-neutral">${escapeHtml2(contentActionLabel(o.action))}</span></td>
30529
30602
  <td><span class="badge ${surfaceTone}">${escapeHtml2(winnabilityClassLabel(o.winnabilityClass))}</span></td>
30530
- <td class="numeric" title="Opportunity score (0\u2013100)">${Math.round(o.score)}</td>
30603
+ <td class="numeric" title="${copy.scoreHeaderTooltip}">${Math.round(o.score)}</td>
30531
30604
  <td>${drivers}</td>
30532
30605
  <td>${ourPage}</td>
30533
30606
  <td>${winning}</td>
30534
30607
  <td><span class="badge tone-neutral">${escapeHtml2(actionConfidenceLabel(o.actionConfidence))}</span></td>
30535
30608
  </tr>`;
30536
30609
  }).join("");
30610
+ const [queryHeader, actionHeader, winnabilityHeader, scoreHeader, whyHeader, ourPageHeader, winningHeader, confidenceHeader] = copy.headers;
30537
30611
  return section(
30538
30612
  {
30539
- id: "content-opportunities",
30540
- eyebrow: "Section 14",
30541
- title: "Content Opportunities",
30542
- intro: "Queries where content work has the clearest path to more AI citations. Opportunity score is 0\u2013100, higher = stronger. Winnability flags whether the cited surface is ownable or ceded to aggregators/editorial."
30613
+ id: ReportSectionIds["content-opportunities"],
30614
+ eyebrow: copy.eyebrow,
30615
+ title: copy.title,
30616
+ intro: copy.intro
30543
30617
  },
30544
- `${highlights}<table class="report-table">
30545
- <thead><tr><th>Query</th><th>Action</th><th>Winnability</th><th class="numeric" title="Opportunity score (0\u2013100)">Score</th><th>Why</th><th>Our page</th><th>Winning competitor</th><th>Confidence</th></tr></thead>
30618
+ `${highlights}<div class="table-scroll"><table class="report-table">
30619
+ <thead><tr><th>${queryHeader}</th><th>${actionHeader}</th><th>${winnabilityHeader}</th><th class="numeric" title="${copy.scoreHeaderTooltip}">${scoreHeader}</th><th>${whyHeader}</th><th>${ourPageHeader}</th><th>${winningHeader}</th><th>${confidenceHeader}</th></tr></thead>
30546
30620
  <tbody>${rows}</tbody>
30547
- </table>`
30621
+ </table></div>`
30548
30622
  );
30549
30623
  }
30550
30624
  function renderContentGaps(report) {
30551
30625
  const gaps = report.contentGaps;
30552
30626
  if (gaps.length === 0) return "";
30627
+ const copy = REPORT_SECTION_COPY["content-gaps"];
30553
30628
  const rows = gaps.slice(0, 10).map((g) => {
30554
- const competitorList = g.competitorDomains.slice(0, 5).map(escapeHtml2).join(", ");
30555
- const more = g.competitorDomains.length > 5 ? `, +${g.competitorDomains.length - 5} more` : "";
30556
30629
  return `<tr>
30557
30630
  <td>${escapeHtml2(g.query)}</td>
30558
30631
  <td class="numeric">${g.competitorCount}</td>
30559
- <td>${competitorList}${more}</td>
30560
- <td class="numeric">${Math.round(g.missRate * 100)}%</td>
30632
+ <td>${escapeHtml2(reportCompactList(g.competitorDomains, 5))}</td>
30633
+ <td class="numeric">${reportMissRateLabel(g.missRate)}</td>
30561
30634
  </tr>`;
30562
30635
  }).join("");
30636
+ const [queryHeader, competitorsHeader, domainsHeader, missRateHeader] = copy.headers;
30563
30637
  return section(
30564
30638
  {
30565
- id: "content-gaps",
30566
- eyebrow: "Section 15",
30567
- title: "Content Gaps",
30568
- intro: "Tracked queries where competitors are cited and the client is missing."
30639
+ id: ReportSectionIds["content-gaps"],
30640
+ eyebrow: copy.eyebrow,
30641
+ title: copy.title,
30642
+ intro: copy.intro
30569
30643
  },
30570
- `<table class="report-table">
30571
- <thead><tr><th>Query</th><th class="numeric">Competitors cited</th><th>Domains</th><th class="numeric">Miss rate</th></tr></thead>
30644
+ `<div class="table-scroll"><table class="report-table">
30645
+ <thead><tr><th>${queryHeader}</th><th class="numeric">${competitorsHeader}</th><th>${domainsHeader}</th><th class="numeric">${missRateHeader}</th></tr></thead>
30572
30646
  <tbody>${rows}</tbody>
30573
- </table>`
30647
+ </table></div>`
30574
30648
  );
30575
30649
  }
30576
30650
  function renderRecommendedNextSteps(report) {
30577
30651
  const steps = report.recommendedNextSteps;
30652
+ const copy = REPORT_SECTION_COPY["recommended-next-steps"];
30653
+ const heading = { id: ReportSectionIds["recommended-next-steps"], eyebrow: copy.eyebrow, title: copy.title, intro: copy.intro };
30578
30654
  if (steps.length === 0) {
30579
- return section(
30580
- { id: "recommended-next-steps", eyebrow: "Section 16", title: "Recommended Next Steps", intro: "Action items bucketed by timing." },
30581
- renderEmpty("No outstanding actions.")
30582
- );
30655
+ return section(heading, renderEmpty(copy.empty));
30583
30656
  }
30584
30657
  const items = steps.map((s) => `
30585
30658
  <div class="step">
@@ -30587,17 +30660,12 @@ function renderRecommendedNextSteps(report) {
30587
30660
  <span class="title">${escapeHtml2(s.title)}</span>
30588
30661
  <span class="rationale">${escapeHtml2(s.rationale)}</span>
30589
30662
  </div>`).join("");
30590
- return section(
30591
- { id: "recommended-next-steps", eyebrow: "Section 16", title: "Recommended Next Steps", intro: "Action items bucketed by timing." },
30592
- `<div class="steps">${items}</div>`
30593
- );
30594
- }
30595
- function actionAudienceMatches(action, audience) {
30596
- return action.audience === "both" || action.audience === audience;
30663
+ return section(heading, `<div class="steps">${items}</div>`);
30597
30664
  }
30598
30665
  function renderActionCards(actions, audience) {
30599
30666
  const isClient = audience === "client";
30600
- if (actions.length === 0) return renderEmpty(isClient ? "No recommendations yet \u2014 run an AI check to populate this." : "No prioritized actions yet.");
30667
+ const copy = isClient ? REPORT_SECTION_COPY["client-action-plan"] : REPORT_SECTION_COPY["agency-action-plan"];
30668
+ if (actions.length === 0) return renderEmpty(copy.empty);
30601
30669
  return `<div class="action-card-grid">
30602
30670
  ${actions.map((action, idx) => {
30603
30671
  const tone = reportActionTone(action);
@@ -30605,17 +30673,17 @@ function renderActionCards(actions, audience) {
30605
30673
  const evidence = action.evidence.length > 0 ? `<ul>${action.evidence.map((item2) => `<li>${escapeHtml2(item2)}</li>`).join("")}</ul>` : "";
30606
30674
  const proof = renderProofChips(action.evidence.length > 0 ? action.evidence : action.why, 3);
30607
30675
  const details = why || evidence ? `<details class="action-details">
30608
- <summary>${isClient ? "See the data behind this" : "Evidence details"}</summary>
30609
- ${why ? `<div><strong>${isClient ? "Why this matters" : "Why"}</strong>${why}</div>` : ""}
30610
- ${evidence ? `<div><strong>${isClient ? "What we saw" : "Evidence"}</strong>${evidence}</div>` : ""}
30676
+ <summary>${copy.detailsSummary}</summary>
30677
+ ${why ? `<div><strong>${copy.whyLabel}</strong>${why}</div>` : ""}
30678
+ ${evidence ? `<div><strong>${copy.evidenceLabel}</strong>${evidence}</div>` : ""}
30611
30679
  </details>` : "";
30612
- const horizonLabel = isClient ? clientHorizonLabel(action.horizon) : reportHorizonLabel(action.horizon);
30613
- const confidenceLabel = isClient ? clientConfidenceLabel(action.confidence) : `${reportConfidenceLabel(action.confidence)} confidence`;
30680
+ const horizonLabel = reportActionHorizonBadge(audience, action.horizon);
30681
+ const confidenceLabel = reportActionConfidenceBadge(audience, action.confidence);
30614
30682
  const categoryBadge = isClient ? "" : `<span class="badge tone-neutral">${escapeHtml2(reportActionCategoryLabel(action.category))}</span>`;
30615
- const successLabel = isClient ? "What success looks like:" : "Win condition:";
30683
+ const successLabel = copy.successLabel;
30616
30684
  return `<article class="action-card">
30617
30685
  <div class="action-head">
30618
- <div class="action-rank" title="${isClient ? "Priority \u2014 1 will move the needle fastest" : "Impact rank \u2014 1 is the highest-leverage action"}">${idx + 1}</div>
30686
+ <div class="action-rank" title="${copy.rankTitle}">${idx + 1}</div>
30619
30687
  <div>
30620
30688
  <div class="action-meta">
30621
30689
  <span class="badge tone-${tone}">${escapeHtml2(horizonLabel)}</span>
@@ -30634,71 +30702,68 @@ function renderActionCards(actions, audience) {
30634
30702
  </div>`;
30635
30703
  }
30636
30704
  function renderAudienceActionPlan(report, audience) {
30637
- const rawActions = audience === "client" ? report.clientSummary.actionItems : report.agencyDiagnostics.priorities.length > 0 ? report.agencyDiagnostics.priorities : report.actionPlan.filter((a) => actionAudienceMatches(a, audience));
30638
- const actions = dedupeReportActions(report, rawActions);
30705
+ const id = audience === "client" ? ReportSectionIds["client-action-plan"] : ReportSectionIds["agency-action-plan"];
30706
+ const copy = REPORT_SECTION_COPY[id];
30639
30707
  return section(
30640
- {
30641
- id: audience === "client" ? "client-action-plan" : "agency-action-plan",
30642
- eyebrow: audience === "client" ? "Action plan" : "Agency actions",
30643
- title: audience === "client" ? "What to do next" : "Agency Action Plan",
30644
- intro: audience === "client" ? "Approve these in order. They are sorted by what will move the needle fastest." : "The highest-leverage work, sorted by urgency and evidence strength."
30645
- },
30646
- renderActionCards(actions, audience)
30708
+ { id, eyebrow: copy.eyebrow, title: copy.title, intro: copy.intro },
30709
+ renderActionCards(reportAudienceActions(report, audience), audience)
30647
30710
  );
30648
30711
  }
30649
30712
  function renderClientSummary(report) {
30650
30713
  const s = report.executiveSummary;
30651
30714
  const sc = report.citationScorecard;
30715
+ const copy = REPORT_SECTION_COPY["client-summary"];
30652
30716
  const totalQ = s.totalQueryCount ?? 0;
30653
30717
  const heroNumber = totalQ > 0 ? `${s.mentionRate}%` : "\u2014";
30654
- const heroSentence = totalQ > 0 ? `When customers asked AI ${totalQ} ${pluralize(totalQ, "query", "queries")} about your industry, AI mentioned you in ${s.mentionedQueryCount} of ${totalQ === 1 ? "them" : "those queries"}.` : "No AI check has been run yet. Run a check to see how AI tools answer customer queries about your business.";
30655
- const trend = clientTrendCopy(report.whatsChanged.mentionRate);
30718
+ const heroSentence = reportClientHeroSentence(totalQ, s.mentionedQueryCount);
30719
+ const trend = reportClientTrendCopy(report.whatsChanged.mentionRate);
30656
30720
  const heroTrend = trend ? `<p class="client-hero-trend tone-${trend.tone}"><span style="margin-right:6px;">${trend.arrow}</span>${escapeHtml2(trend.text)}</p>` : "";
30657
30721
  const hero = `<div class="client-hero">
30658
- <div class="client-hero-eyebrow">Overview</div>
30722
+ <div class="client-hero-eyebrow">${copy.heroEyebrow}</div>
30659
30723
  <div class="client-hero-number">${heroNumber}</div>
30660
30724
  <p class="client-hero-sentence">${escapeHtml2(heroSentence)}</p>
30661
30725
  ${heroTrend}
30662
30726
  </div>`;
30663
- const providerSubtitle = sc.providers.length > 0 ? sc.providers.map(providerDisplayName).join(", ") : `${formatNumber(s.queryCount)} ${pluralize(s.queryCount, "query", "queries")} tested`;
30727
+ const providerSubtitle = reportClientProvidersSubtitle(sc.providers, s.queryCount);
30664
30728
  const tiles = `<div class="client-metric-grid">
30665
30729
  <div class="client-metric-tile">
30666
- <div class="label">AI mentions your name</div>
30730
+ <div class="label">${copy.tiles.mentioned}</div>
30667
30731
  <div class="value">${s.mentionRate}%</div>
30668
- <div class="subtitle">${totalQ > 0 ? `Says your name in ${s.mentionedQueryCount} of ${totalQ} ${pluralize(totalQ, "query", "queries")}` : "No data yet"}</div>
30732
+ <div class="subtitle">${reportClientMentionedSubtitle(s.mentionedQueryCount, totalQ)}</div>
30669
30733
  </div>
30670
30734
  <div class="client-metric-tile">
30671
- <div class="label">AI links to your website</div>
30735
+ <div class="label">${copy.tiles.cited}</div>
30672
30736
  <div class="value">${s.citationRate}%</div>
30673
- <div class="subtitle">${totalQ > 0 ? `Cites your site as a source in ${s.citedQueryCount} of ${totalQ} ${pluralize(totalQ, "query", "queries")}` : "No data yet"}</div>
30737
+ <div class="subtitle">${reportClientCitedSubtitle(s.citedQueryCount, totalQ)}</div>
30674
30738
  </div>
30675
30739
  <div class="client-metric-tile">
30676
- <div class="label">AI tools tested</div>
30740
+ <div class="label">${copy.tiles.providers}</div>
30677
30741
  <div class="value">${formatNumber(s.providerCount)}</div>
30678
30742
  <div class="subtitle">${escapeHtml2(providerSubtitle)}</div>
30679
30743
  </div>
30680
30744
  </div>`;
30745
+ const { lead, mention, link, closing } = copy.explainer;
30681
30746
  const explainer = `<div class="client-explainer">
30682
- <strong>Mentions and links are different.</strong>
30683
- A <span class="term">mention</span> is when AI says your name out loud in its answer.
30684
- A <span class="term">link</span> is when AI lists your website as a source it used.
30685
- AI can do either, both, or neither \u2014 that's why we track both.
30747
+ <strong>${lead}</strong>
30748
+ ${mention.article} <span class="term">${mention.term}</span> ${mention.definition}
30749
+ ${link.article} <span class="term">${link.term}</span> ${link.definition}
30750
+ ${closing}
30686
30751
  </div>`;
30687
30752
  const questions = sc.queries.length > 0 ? `<div class="client-card">
30688
- <h3>Customer queries we tested</h3>
30689
- <p class="card-subtitle">These are the ${sc.queries.length} ${pluralize(sc.queries.length, "query we asked", "queries we asked")} every AI tool. The numbers above measure how often you came up.</p>
30753
+ <h3>${copy.queriesHeading}</h3>
30754
+ <p class="card-subtitle">${reportClientQueriesSubtitle(sc.queries.length)}</p>
30690
30755
  <ol class="client-questions-list">
30691
30756
  ${sc.queries.map((q, i) => `<li><span class="qnum">${String(i + 1).padStart(2, "0")}</span><span>"${escapeHtml2(q)}"</span></li>`).join("")}
30692
30757
  </ol>
30693
30758
  </div>` : "";
30694
30759
  const providerBars = sc.providerRates.length > 0 ? `<div class="client-card">
30695
- <h3>How often each AI tool mentions you</h3>
30696
- <p class="card-subtitle">Higher is better. Each bar shows the share of customer queries where the AI named you in the answer.</p>
30760
+ <h3>${copy.providerBarsHeading}</h3>
30761
+ <p class="card-subtitle">${copy.providerBarsSubtitle}</p>
30697
30762
  <div class="client-bar-list">
30698
30763
  ${sc.providerRates.map((r) => {
30699
30764
  const pct = Math.max(r.mentionRate, 1.5);
30700
30765
  return `<div class="client-bar-row">
30701
- <span class="bar-label">${escapeHtml2(providerDisplayName(r.provider))}</span>
30766
+ <span class="bar-label">${escapeHtml2(reportProviderDisplayName(r.provider))}</span>
30702
30767
  <div class="bar-track"><div class="bar-fill" style="width:${pct}%"></div></div>
30703
30768
  <span class="bar-value">${r.mentionRate}% <span class="bar-value-sub">(${r.mentionedCount}/${r.totalCount})</span></span>
30704
30769
  </div>`;
@@ -30715,34 +30780,35 @@ function renderClientEvidenceSummary(report) {
30715
30780
  const opportunities = dedupeReportOpportunities(report).slice(0, 5);
30716
30781
  const aiMax = ai.length > 0 ? Math.max(...ai.map((d) => d.count)) : 0;
30717
30782
  const gscMax = gsc ? Math.max(...gsc.topQueries.slice(0, 5).map((q) => q.impressions), 1) : 0;
30783
+ const copy = REPORT_SECTION_COPY["client-evidence-summary"];
30718
30784
  const cards = [];
30719
30785
  if (ai.length > 0) {
30720
30786
  cards.push(`<div class="client-card">
30721
- <h3>Where AI gets its answers</h3>
30722
- <p class="card-subtitle">The websites AI tools cited most often when answering customer queries about your industry.</p>
30787
+ <h3>${copy.sources.heading}</h3>
30788
+ <p class="card-subtitle">${copy.sources.subtitle}</p>
30723
30789
  <div class="client-bar-list">
30724
30790
  ${ai.map((d) => {
30725
30791
  const pct = aiMax > 0 ? Math.max(d.count / aiMax * 100, 1.5) : 0;
30726
- const label = escapeHtml2(d.domain) + (d.isCompetitor ? ' <span style="color:' + COLORS.textFaint + ';font-size:11px;">(competitor)</span>' : "");
30792
+ const label = escapeHtml2(d.domain) + (d.isCompetitor ? ` <span style="color:${COLORS.textFaint};font-size:11px;">${copy.sources.competitorTag}</span>` : "");
30727
30793
  return `<div class="client-bar-row">
30728
30794
  <span class="bar-label">${label}</span>
30729
30795
  <div class="bar-track"><div class="bar-fill bar-fill-neutral" style="width:${pct}%"></div></div>
30730
- <span class="bar-value">${formatNumber(d.count)}\xD7</span>
30796
+ <span class="bar-value">${reportClientSourceCount(d.count)}</span>
30731
30797
  </div>`;
30732
30798
  }).join("")}
30733
30799
  </div>
30734
30800
  </div>`);
30735
30801
  }
30736
30802
  if (indexing) {
30737
- const tone = indexing.indexedPct >= 90 ? "positive" : indexing.indexedPct >= 70 ? "caution" : "negative";
30803
+ const tone = reportClientIndexingTone(indexing.indexedPct);
30738
30804
  const fillPct = Math.max(indexing.indexedPct, 1.5);
30739
30805
  cards.push(`<div class="client-card">
30740
- <h3>Pages Google can find on your site</h3>
30741
- <p class="card-subtitle">Google indexing your site increases the chances of it appearing in AI search (especially Gemini).</p>
30806
+ <h3>${copy.indexing.heading}</h3>
30807
+ <p class="card-subtitle">${copy.indexing.subtitle}</p>
30742
30808
  <div class="client-progress-number tone-${tone}">${indexing.indexedPct}%</div>
30743
- <div style="font-size:12px;color:${COLORS.textMuted};">${formatNumber(indexing.indexed)} of ${formatNumber(indexing.total)} pages indexed</div>
30809
+ <div style="font-size:12px;color:${COLORS.textMuted};">${reportClientIndexedPages(indexing.indexed, indexing.total)}</div>
30744
30810
  <div class="client-progress-bar"><div class="client-progress-fill tone-${tone}" style="width:${fillPct}%"></div></div>
30745
- <p style="margin:0;font-size:12px;color:${COLORS.textMuted};"><strong style="color:${COLORS.text};">${formatNumber(indexing.notIndexed)}</strong> ${pluralize(indexing.notIndexed, "page is", "pages are")} not indexed yet.</p>
30811
+ <p style="margin:0;font-size:12px;color:${COLORS.textMuted};"><strong style="color:${COLORS.text};">${formatNumber(indexing.notIndexed)}</strong> ${reportClientNotIndexedTail(indexing.notIndexed)}</p>
30746
30812
  </div>`);
30747
30813
  }
30748
30814
  if (gsc) {
@@ -30753,53 +30819,54 @@ function renderClientEvidenceSummary(report) {
30753
30819
  return `<div class="client-bar-row">
30754
30820
  <span class="bar-label">${escapeHtml2(q.query)}</span>
30755
30821
  <div class="bar-track"><div class="bar-fill bar-fill-sky" style="width:${pct}%"></div></div>
30756
- <span class="bar-value">${formatNumber(q.impressions)} ${pluralize(q.impressions, "search", "searches")}</span>
30822
+ <span class="bar-value">${reportClientSearchCount(q.impressions)}</span>
30757
30823
  </div>`;
30758
30824
  }).join("")}
30759
30825
  </div>` : "";
30760
30826
  cards.push(`<div class="client-card">
30761
- <h3>What people search Google for</h3>
30762
- <p class="card-subtitle">You appeared in <strong style="color:${COLORS.text};">${formatNumber(gsc.totalImpressions)}</strong> Google searches and got <strong style="color:${COLORS.text};">${formatNumber(gsc.totalClicks)}</strong> ${pluralize(gsc.totalClicks, "click")} this period.</p>
30827
+ <h3>${copy.search.heading}</h3>
30828
+ <p class="card-subtitle">${copy.search.subtitleLead} <strong style="color:${COLORS.text};">${formatNumber(gsc.totalImpressions)}</strong> ${copy.search.subtitleMiddle} <strong style="color:${COLORS.text};">${formatNumber(gsc.totalClicks)}</strong> ${reportClientClicksNoun(gsc.totalClicks)} ${copy.search.subtitleTail}</p>
30763
30829
  ${queryRows2}
30764
30830
  </div>`);
30765
30831
  }
30766
30832
  if (opportunities.length > 0) {
30767
30833
  cards.push(`<div class="client-card">
30768
- <h3>Topics where you could improve</h3>
30769
- <p class="card-subtitle">Customer queries where better content on your site would help AI cite you.</p>
30834
+ <h3>${copy.opportunities.heading}</h3>
30835
+ <p class="card-subtitle">${copy.opportunities.subtitle}</p>
30770
30836
  <ul class="client-opportunity-list">
30771
30837
  ${opportunities.map((o) => `<li>
30772
30838
  <div class="op-query">${escapeHtml2(o.query)}</div>
30773
- <div class="op-action">${escapeHtml2(contentActionLabel(o.action))}${o.winnabilityClass === "ceded" ? ' <span class="badge tone-caution">Ceded surface</span>' : ""}</div>
30839
+ <div class="op-action">${escapeHtml2(contentActionLabel(o.action))}${o.winnabilityClass === "ceded" ? ` <span class="badge tone-caution">${copy.opportunities.cededTag}</span>` : ""}</div>
30774
30840
  </li>`).join("")}
30775
30841
  </ul>
30776
30842
  </div>`);
30777
30843
  }
30778
30844
  return section(
30779
30845
  {
30780
- id: "client-evidence-summary",
30781
- eyebrow: "What we based this on",
30782
- title: "The signals behind this plan",
30783
- intro: "The data behind the recommendations above. Switch to Agency for the full breakdowns."
30846
+ id: ReportSectionIds["client-evidence-summary"],
30847
+ eyebrow: copy.eyebrow,
30848
+ title: copy.title,
30849
+ intro: copy.intro
30784
30850
  },
30785
- cards.length > 0 ? `<div class="client-evidence-grid">${cards.join("")}</div>` : renderEmpty("No supporting evidence yet \u2014 this fills in after the first AI check.")
30851
+ cards.length > 0 ? `<div class="client-evidence-grid">${cards.join("")}</div>` : renderEmpty(copy.empty)
30786
30852
  );
30787
30853
  }
30788
30854
  function renderAgencyDiagnostics(report) {
30789
- const diagnostics = report.agencyDiagnostics.diagnostics.filter((d) => d.title !== "Location caveat");
30855
+ const copy = REPORT_SECTION_COPY["agency-diagnostics"];
30856
+ const diagnostics = report.agencyDiagnostics.diagnostics.filter((d) => d.title !== copy.hiddenTitle);
30790
30857
  const body = diagnostics.length > 0 ? `<div class="diagnostics-grid">
30791
30858
  ${diagnostics.map((d) => `<div class="diagnostic-card tone-${d.severity}">
30792
30859
  <h3>${escapeHtml2(d.title)}</h3>
30793
30860
  <p>${escapeHtml2(d.detail)}</p>
30794
30861
  ${renderProofChips(d.evidence, 3)}
30795
30862
  </div>`).join("")}
30796
- </div>` : renderEmpty("No agency diagnostics available yet.");
30863
+ </div>` : renderEmpty(copy.empty);
30797
30864
  return section(
30798
30865
  {
30799
- id: "agency-diagnostics",
30800
- eyebrow: "Agency diagnostics",
30801
- title: "Technical Diagnostics",
30802
- intro: "Fast-read operator flags behind the action plan."
30866
+ id: ReportSectionIds["agency-diagnostics"],
30867
+ eyebrow: copy.eyebrow,
30868
+ title: copy.title,
30869
+ intro: copy.intro
30803
30870
  },
30804
30871
  body
30805
30872
  );
@@ -30855,9 +30922,9 @@ function renderReportHtml(report, opts = {}) {
30855
30922
  <body>
30856
30923
  <div class="container">
30857
30924
  <header class="header">
30858
- <div class="eyebrow">AI Visibility Report</div>
30925
+ <div class="eyebrow">${REPORT_HEADER_COPY.eyebrow}</div>
30859
30926
  <h1>${escapeHtml2(report.meta.project.displayName)}</h1>
30860
- <div class="subtitle">${escapeHtml2(report.meta.project.canonicalDomain)} \xB7 ${escapeHtml2(report.meta.project.country)} / ${escapeHtml2(report.meta.project.language.toUpperCase())}${report.visibility?.selection.mode === "advanced" ? ` \xB7 ${escapeHtml2(reportVisibilityLocationLabel(report.visibility))}` : renderHeaderLocationFragment(report.meta.location)} \xB7 Last ${report.meta.periodDays} days \xB7 Generated ${formatDate(report.meta.generatedAt)}</div>
30927
+ <div class="subtitle">${escapeHtml2(report.meta.project.canonicalDomain)} \xB7 ${escapeHtml2(report.meta.project.country)} / ${escapeHtml2(report.meta.project.language.toUpperCase())}${report.visibility?.selection.mode === "advanced" ? ` \xB7 ${escapeHtml2(reportVisibilityLocationLabel(report.visibility))}` : renderHeaderLocationFragment(report.meta.location)} \xB7 ${reportHeaderPeriodLabel(report.meta.periodDays)} \xB7 ${REPORT_HEADER_COPY.generated} ${formatDate(report.meta.generatedAt)}</div>
30861
30928
  </header>
30862
30929
  ${sections}
30863
30930
  <footer class="footer">Generated by <a href="https://canonry.ai">canonry</a> \xB7 ${escapeHtml2(formatIsoDate(report.meta.generatedAt))}</footer>
@@ -30871,8 +30938,8 @@ function renderReportVisibility(visibility) {
30871
30938
  const historyPopulations = visibility.populations.filter((population) => population.queryClass !== "unknown" || population.trend.some((point) => point.answerCount > 0));
30872
30939
  const rateCell = (rate2) => `<td><strong>${escapeHtml2(reportVisibilityRate(rate2))}</strong><p class="muted">${escapeHtml2(reportVisibilityEvidence(rate2))}</p></td>`;
30873
30940
  const headers = (labels) => `<thead><tr>${labels.map((label) => `<th>${escapeHtml2(label)}</th>`).join("")}</tr></thead>`;
30874
- const summary2 = `<table>${headers([REPORT_VISIBILITY_COPY.queryType, REPORT_VISIBILITY_COPY.queries, REPORT_VISIBILITY_COPY.answers, REPORT_VISIBILITY_COPY.mentioned, REPORT_VISIBILITY_COPY.cited])}<tbody>${populations.map((population) => `<tr><td>${escapeHtml2(reportQueryClassLabel(population.queryClass))}</td><td>${population.summary.queryCount}</td><td>${population.summary.answerCount}</td>${rateCell(population.summary.mentionCoverage)}${rateCell(population.summary.citationCoverage)}</tr>`).join("")}</tbody></table>`;
30875
- const trend = `<details><summary>${escapeHtml2(reportVisibilityHistoryLabel(visibility))}</summary><div class="table-scroll"><table>${headers([REPORT_VISIBILITY_COPY.date, REPORT_VISIBILITY_COPY.queryType, REPORT_VISIBILITY_COPY.mentioned, REPORT_VISIBILITY_COPY.cited, REPORT_VISIBILITY_COPY.comparison])}<tbody>${historyPopulations.flatMap((population) => population.trend.map((point) => `<tr><td><time datetime="${escapeHtml2(point.createdAt)}">${escapeHtml2(point.createdAt.slice(0, 10))}</time></td><td>${escapeHtml2(reportQueryClassLabel(population.queryClass))}</td>${rateCell(point.mentionCoverage)}${rateCell(point.citationCoverage)}<td>${escapeHtml2(reportVisibilityComparison(point.continuity.state, point.continuity.comparedRunId !== null && !population.trend.some((previous) => previous.runId === point.continuity.comparedRunId)))}</td></tr>`)).join("")}</tbody></table></div></details>`;
30941
+ const summary2 = `<table class="report-table">${headers([REPORT_VISIBILITY_COPY.queryType, REPORT_VISIBILITY_COPY.queries, REPORT_VISIBILITY_COPY.answers, REPORT_VISIBILITY_COPY.mentioned, REPORT_VISIBILITY_COPY.cited])}<tbody>${populations.map((population) => `<tr><td>${escapeHtml2(reportQueryClassLabel(population.queryClass))}</td><td>${population.summary.queryCount}</td><td>${population.summary.answerCount}</td>${rateCell(population.summary.mentionCoverage)}${rateCell(population.summary.citationCoverage)}</tr>`).join("")}</tbody></table>`;
30942
+ const trend = `<details><summary>${escapeHtml2(reportVisibilityHistoryLabel(visibility))}</summary><div class="table-scroll"><table class="report-table">${headers([REPORT_VISIBILITY_COPY.date, REPORT_VISIBILITY_COPY.queryType, REPORT_VISIBILITY_COPY.mentioned, REPORT_VISIBILITY_COPY.cited, REPORT_VISIBILITY_COPY.comparison])}<tbody>${historyPopulations.flatMap((population) => population.trend.map((point) => `<tr><td><time datetime="${escapeHtml2(point.createdAt)}">${escapeHtml2(point.createdAt.slice(0, 10))}</time></td><td>${escapeHtml2(reportQueryClassLabel(population.queryClass))}</td>${rateCell(point.mentionCoverage)}${rateCell(point.citationCoverage)}<td>${escapeHtml2(reportVisibilityComparison(point.continuity.state, point.continuity.comparedRunId !== null && !population.trend.some((previous) => previous.runId === point.continuity.comparedRunId)))}</td></tr>`)).join("")}</tbody></table></div></details>`;
30876
30943
  return `<section id="client-summary" class="report-section" aria-label="${escapeHtml2(REPORT_VISIBILITY_COPY.title)}"><h2>${escapeHtml2(REPORT_VISIBILITY_COPY.title)}</h2><p>${escapeHtml2(visibility.selection.mode === "advanced" ? REPORT_VISIBILITY_COPY.description : REPORT_VISIBILITY_COPY.simpleDescription)}</p><p>${escapeHtml2(reportVisibilityMeasurementLabel(visibility))}</p><div class="table-scroll">${summary2}</div>${trend}</section>`;
30877
30944
  }
30878
30945
 
@@ -31402,7 +31469,7 @@ function buildServerActivity(db, projectId, windowDays2) {
31402
31469
  const sourceRows = db.select({ id: trafficSources.id }).from(trafficSources).where(
31403
31470
  and29(
31404
31471
  eq33(trafficSources.projectId, projectId),
31405
- ne6(trafficSources.status, TrafficSourceStatuses.archived)
31472
+ ne5(trafficSources.status, TrafficSourceStatuses.archived)
31406
31473
  )
31407
31474
  ).all();
31408
31475
  if (sourceRows.length === 0) return null;
@@ -31420,7 +31487,7 @@ function buildServerActivity(db, projectId, windowDays2) {
31420
31487
  eq33(crawlerEventsHourly.projectId, projectId),
31421
31488
  eq33(crawlerEventsHourly.verificationStatus, VerificationStatuses.verified),
31422
31489
  gte8(crawlerEventsHourly.tsHour, windowStartIso),
31423
- exclusiveEnd ? lt5(crawlerEventsHourly.tsHour, windowEndIso) : lte6(crawlerEventsHourly.tsHour, windowEndIso)
31490
+ exclusiveEnd ? lt6(crawlerEventsHourly.tsHour, windowEndIso) : lte6(crawlerEventsHourly.tsHour, windowEndIso)
31424
31491
  )
31425
31492
  ).get()?.total ?? 0
31426
31493
  );
@@ -31428,9 +31495,9 @@ function buildServerActivity(db, projectId, windowDays2) {
31428
31495
  db.select({ total: sql11`COALESCE(SUM(${crawlerEventsHourly.hits}), 0)` }).from(crawlerEventsHourly).where(
31429
31496
  and29(
31430
31497
  eq33(crawlerEventsHourly.projectId, projectId),
31431
- ne6(crawlerEventsHourly.verificationStatus, VerificationStatuses.verified),
31498
+ ne5(crawlerEventsHourly.verificationStatus, VerificationStatuses.verified),
31432
31499
  gte8(crawlerEventsHourly.tsHour, windowStartIso),
31433
- exclusiveEnd ? lt5(crawlerEventsHourly.tsHour, windowEndIso) : lte6(crawlerEventsHourly.tsHour, windowEndIso)
31500
+ exclusiveEnd ? lt6(crawlerEventsHourly.tsHour, windowEndIso) : lte6(crawlerEventsHourly.tsHour, windowEndIso)
31434
31501
  )
31435
31502
  ).get()?.total ?? 0
31436
31503
  );
@@ -31444,7 +31511,7 @@ function buildServerActivity(db, projectId, windowDays2) {
31444
31511
  eq33(aiReferralEventsHourly.projectId, projectId),
31445
31512
  countableReferralCondition(),
31446
31513
  gte8(aiReferralEventsHourly.tsHour, windowStartIso),
31447
- exclusiveEnd ? lt5(aiReferralEventsHourly.tsHour, windowEndIso) : lte6(aiReferralEventsHourly.tsHour, windowEndIso)
31514
+ exclusiveEnd ? lt6(aiReferralEventsHourly.tsHour, windowEndIso) : lte6(aiReferralEventsHourly.tsHour, windowEndIso)
31448
31515
  )
31449
31516
  ).get();
31450
31517
  return aiReferralClassCounts(Number(row?.total ?? 0), Number(row?.paid ?? 0), Number(row?.organic ?? 0));
@@ -31454,7 +31521,7 @@ function buildServerActivity(db, projectId, windowDays2) {
31454
31521
  and29(
31455
31522
  eq33(aiUserFetchEventsHourly.projectId, projectId),
31456
31523
  gte8(aiUserFetchEventsHourly.tsHour, windowStartIso),
31457
- exclusiveEnd ? lt5(aiUserFetchEventsHourly.tsHour, windowEndIso) : lte6(aiUserFetchEventsHourly.tsHour, windowEndIso)
31524
+ exclusiveEnd ? lt6(aiUserFetchEventsHourly.tsHour, windowEndIso) : lte6(aiUserFetchEventsHourly.tsHour, windowEndIso)
31458
31525
  )
31459
31526
  ).get()?.total ?? 0
31460
31527
  );
@@ -31495,7 +31562,7 @@ function buildServerActivity(db, projectId, windowDays2) {
31495
31562
  and29(
31496
31563
  eq33(crawlerEventsHourly.projectId, projectId),
31497
31564
  gte8(crawlerEventsHourly.tsHour, priorStart),
31498
- lt5(crawlerEventsHourly.tsHour, headlineStart)
31565
+ lt6(crawlerEventsHourly.tsHour, headlineStart)
31499
31566
  )
31500
31567
  ).groupBy(crawlerEventsHourly.operator).all();
31501
31568
  const referralByOperatorRows = db.select({
@@ -31786,7 +31853,7 @@ function buildInsightList(db, projectId, locationFilter) {
31786
31853
  and29(
31787
31854
  eq33(runs.projectId, projectId),
31788
31855
  eq33(runs.kind, RunKinds["answer-visibility"]),
31789
- or5(eq33(runs.status, RunStatuses.completed), eq33(runs.status, RunStatuses.partial)),
31856
+ or6(eq33(runs.status, RunStatuses.completed), eq33(runs.status, RunStatuses.partial)),
31790
31857
  notProbeRun()
31791
31858
  )
31792
31859
  ).orderBy(desc14(runs.createdAt)).all().filter((r) => locationFilter === void 0 || (r.location ?? null) === locationFilter).slice(0, INSIGHT_LOOKBACK_RUNS).map((r) => r.id);
@@ -31932,11 +31999,6 @@ function buildProviderLocationHandling(providersInRun) {
31932
31999
  };
31933
32000
  });
31934
32001
  }
31935
- function compactList(items, limit = 3) {
31936
- const visible = items.slice(0, limit);
31937
- const extra = items.length - visible.length;
31938
- return extra > 0 ? `${visible.join(", ")}, +${extra} more` : visible.join(", ");
31939
- }
31940
32002
  function contentActionVerb(action) {
31941
32003
  switch (action) {
31942
32004
  case "create":
@@ -31954,7 +32016,7 @@ function confidenceFromEvidence(count4) {
31954
32016
  if (count4 >= 1) return "medium";
31955
32017
  return "low";
31956
32018
  }
31957
- function actionAudienceMatches2(action, audience) {
32019
+ function actionAudienceMatches(action, audience) {
31958
32020
  return action.audience === "both" || action.audience === audience;
31959
32021
  }
31960
32022
  function configuredCompetitorDomainsForActionPlan(db, projectId, projectCompetitorDomains) {
@@ -32065,10 +32127,10 @@ function buildReportActionPlan(input) {
32065
32127
  if (input.gsc && (input.gsc.trackedButNoGsc.length > 0 || input.gsc.gscButNotTracked.length > 0)) {
32066
32128
  const evidence = [];
32067
32129
  if (input.gsc.trackedButNoGsc.length > 0) {
32068
- evidence.push(`Tracked with no GSC demand: ${compactList(input.gsc.trackedButNoGsc)}`);
32130
+ evidence.push(`Tracked with no GSC demand: ${reportCompactList(input.gsc.trackedButNoGsc)}`);
32069
32131
  }
32070
32132
  if (input.gsc.gscButNotTracked.length > 0) {
32071
- evidence.push(`Search demand not tracked in AEO: ${compactList(input.gsc.gscButNotTracked)}`);
32133
+ evidence.push(`Search demand not tracked in AEO: ${reportCompactList(input.gsc.gscButNotTracked)}`);
32072
32134
  }
32073
32135
  actions.push({
32074
32136
  audience: "agency",
@@ -32100,8 +32162,8 @@ function buildReportActionPlan(input) {
32100
32162
  "They are stronger evidence than a generic topic list because the model is already retrieving competing content."
32101
32163
  ],
32102
32164
  evidence: [
32103
- `"${topGap.query}" missed at ${Math.round(topGap.missRate * 100)}% with ${topGap.competitorCount} competitor${topGap.competitorCount === 1 ? "" : "s"} cited`,
32104
- `Cited competitors: ${compactList(topGap.competitorDomains)}`
32165
+ `"${topGap.query}" missed at ${formatWholePercent(topGap.missRate)} with ${topGap.competitorCount} competitor${topGap.competitorCount === 1 ? "" : "s"} cited`,
32166
+ `Cited competitors: ${reportCompactList(topGap.competitorDomains)}`
32105
32167
  ],
32106
32168
  successMetric: "The top content-gap query moves from missed to cited or mentioned after the recommended content work ships.",
32107
32169
  confidence: topGap.competitorCount >= 2 ? "high" : "medium"
@@ -32111,10 +32173,10 @@ function buildReportActionPlan(input) {
32111
32173
  const ignoredProviders = input.providerLocationHandling.filter((p) => p.treatment === "ignored" || p.treatment === "browser-geo").map((p) => p.provider);
32112
32174
  const evidence = [
32113
32175
  `Current report location: ${input.reportLocation.label}`,
32114
- `Other configured locations: ${compactList(input.reportLocation.otherConfiguredLabels)}`
32176
+ `Other configured locations: ${reportCompactList(input.reportLocation.otherConfiguredLabels)}`
32115
32177
  ];
32116
32178
  if (ignoredProviders.length > 0) {
32117
- evidence.push(`Providers with weak/indirect location handling: ${compactList(ignoredProviders)}`);
32179
+ evidence.push(`Providers with weak/indirect location handling: ${reportCompactList(ignoredProviders)}`);
32118
32180
  }
32119
32181
  actions.push({
32120
32182
  audience: "agency",
@@ -32186,7 +32248,7 @@ function buildClientSummary(reportLike) {
32186
32248
  return {
32187
32249
  headline,
32188
32250
  overview,
32189
- actionItems: reportLike.actionPlan.filter((a) => actionAudienceMatches2(a, "client")).slice(0, 3),
32251
+ actionItems: reportLike.actionPlan.filter((a) => actionAudienceMatches(a, "client")).slice(0, 3),
32190
32252
  confidenceNotes
32191
32253
  };
32192
32254
  }
@@ -32211,8 +32273,8 @@ function buildAgencyDiagnostics(input) {
32211
32273
  detail: input.gsc.trackedButNoGsc.length > 0 || input.gsc.gscButNotTracked.length > 0 ? "The tracked AEO query set and real search demand are not fully aligned." : "Tracked AEO queries and high-impression non-brand GSC queries are aligned for the current window.",
32212
32274
  severity: input.gsc.trackedButNoGsc.length > 0 || input.gsc.gscButNotTracked.length > 0 ? "caution" : "positive",
32213
32275
  evidence: [
32214
- ...input.gsc.trackedButNoGsc.length > 0 ? [`Tracked with no GSC demand: ${compactList(input.gsc.trackedButNoGsc)}`] : [],
32215
- ...input.gsc.gscButNotTracked.length > 0 ? [`GSC queries not tracked in AEO: ${compactList(input.gsc.gscButNotTracked)}`] : []
32276
+ ...input.gsc.trackedButNoGsc.length > 0 ? [`Tracked with no GSC demand: ${reportCompactList(input.gsc.trackedButNoGsc)}`] : [],
32277
+ ...input.gsc.gscButNotTracked.length > 0 ? [`GSC queries not tracked in AEO: ${reportCompactList(input.gsc.gscButNotTracked)}`] : []
32216
32278
  ]
32217
32279
  });
32218
32280
  }
@@ -32237,7 +32299,7 @@ function buildAgencyDiagnostics(input) {
32237
32299
  evidence: input.contentOpportunities.slice(0, 3).map((o) => `${o.query}: ${o.action} (${Math.round(o.score)})`)
32238
32300
  });
32239
32301
  return {
32240
- priorities: input.actionPlan.filter((a) => actionAudienceMatches2(a, "agency")).slice(0, 6),
32302
+ priorities: input.actionPlan.filter((a) => actionAudienceMatches(a, "agency")).slice(0, 6),
32241
32303
  diagnostics
32242
32304
  };
32243
32305
  }
@@ -32357,7 +32419,7 @@ function buildWhatsChanged(input) {
32357
32419
  function buildProjectReport(db, projectName, periodDays) {
32358
32420
  const project = resolveProject(db, projectName);
32359
32421
  const queryLookup = loadQueryLookup(db, project.id);
32360
- const canonicalVisibility = readVisibilityReport(db, project, { queryClass: "all", scope: "project" });
32422
+ const canonicalVisibility = readVisibilityReport(db, project, { queryClass: "all", scope: "project" }, { includeComparison: false });
32361
32423
  const generatedAt = (/* @__PURE__ */ new Date()).toISOString();
32362
32424
  const historyWindow = { from: new Date(Date.parse(generatedAt) - periodDays * 864e5).toISOString(), to: generatedAt };
32363
32425
  const visibility = {
@@ -32668,10 +32730,10 @@ async function reportRoutes(app) {
32668
32730
  }
32669
32731
 
32670
32732
  // ../api-routes/src/organic-evidence.ts
32671
- import { and as and31, desc as desc15, eq as eq35, gte as gte10, lte as lte8, or as or7, sql as sql13 } from "drizzle-orm";
32733
+ import { and as and31, desc as desc15, eq as eq35, gte as gte10, lte as lte8, or as or8, sql as sql13 } from "drizzle-orm";
32672
32734
 
32673
32735
  // ../api-routes/src/ga-measurement-analysis.ts
32674
- import { and as and30, eq as eq34, gte as gte9, lte as lte7, or as or6, sql as sql12 } from "drizzle-orm";
32736
+ import { and as and30, eq as eq34, gte as gte9, lte as lte7, or as or7, sql as sql12 } from "drizzle-orm";
32675
32737
  var UNKNOWN_LANDING_PAGE = "(not set)";
32676
32738
  function addDays(date, offset) {
32677
32739
  const value = /* @__PURE__ */ new Date(`${date}T00:00:00.000Z`);
@@ -32845,7 +32907,7 @@ function buildGaMeasurementAnalysis(db, projectName, options = {}) {
32845
32907
  if (parsedHostScope.data === "marketing") {
32846
32908
  const lowerHost = sql12`lower(${hostColumn})`;
32847
32909
  const normalizedHost2 = sql12`case when ${lowerHost} like 'www.%' then substr(${lowerHost}, 5) else ${lowerHost} end`;
32848
- conditions.push(or6(...marketingHosts.flatMap((host) => [
32910
+ conditions.push(or7(...marketingHosts.flatMap((host) => [
32849
32911
  sql12`${normalizedHost2} = ${host}`,
32850
32912
  sql12`${normalizedHost2} like ${`%.${host}`}`
32851
32913
  ])));
@@ -32853,14 +32915,14 @@ function buildGaMeasurementAnalysis(db, projectName, options = {}) {
32853
32915
  if (pathPrefix && pathPrefix !== "/") {
32854
32916
  const rawPath = sql12`case when instr(${rawPathColumn}, '?') = 0 then ${rawPathColumn} else substr(${rawPathColumn}, 1, instr(${rawPathColumn}, '?') - 1) end`;
32855
32917
  const landingPath = sql12`case when trim(coalesce(${normalizedPathColumn}, '')) = '' then ${rawPath} else ${normalizedPathColumn} end`;
32856
- conditions.push(or6(sql12`${landingPath} = ${pathPrefix}`, sql12`${landingPath} like ${`${pathPrefix}/%`}`));
32918
+ conditions.push(or7(sql12`${landingPath} = ${pathPrefix}`, sql12`${landingPath} like ${`${pathPrefix}/%`}`));
32857
32919
  }
32858
32920
  return conditions;
32859
32921
  };
32860
32922
  const acquisitionScope = scopedConditions(gaAcquisitionDaily.hostName, gaAcquisitionDaily.landingPageNormalized, gaAcquisitionDaily.landingPage);
32861
32923
  const leadLandingScope = scopedConditions(gaLeadEventsDaily.hostName, gaLeadEventsDaily.landingPageNormalized, gaLeadEventsDaily.landingPage);
32862
32924
  const acquisitionAnchor = db.select({ date: sql12`max(${gaAcquisitionDaily.date})` }).from(gaAcquisitionDaily).where(and30(eq34(gaAcquisitionDaily.projectId, project.id), ...acquisitionScope)).get()?.date ?? null;
32863
- const leadAnchor = db.select({ date: sql12`max(${gaLeadEventsDaily.date})` }).from(gaLeadEventsDaily).where(and30(eq34(gaLeadEventsDaily.projectId, project.id), or6(
32925
+ const leadAnchor = db.select({ date: sql12`max(${gaLeadEventsDaily.date})` }).from(gaLeadEventsDaily).where(and30(eq34(gaLeadEventsDaily.projectId, project.id), or7(
32864
32926
  eq34(gaLeadEventsDaily.attributionScope, "channel"),
32865
32927
  and30(eq34(gaLeadEventsDaily.attributionScope, "landing-page"), ...leadLandingScope)
32866
32928
  ))).get()?.date ?? null;
@@ -32881,7 +32943,7 @@ function buildGaMeasurementAnalysis(db, projectName, options = {}) {
32881
32943
  date: sql12`min(${gaDailyTotals.date})`
32882
32944
  }).from(gaDailyTotals).where(and30(
32883
32945
  eq34(gaDailyTotals.projectId, project.id),
32884
- or6(
32946
+ or7(
32885
32947
  sql12`${gaDailyTotals.engagementRate} is not null`,
32886
32948
  sql12`${gaDailyTotals.newUsers} is not null`
32887
32949
  )
@@ -33228,7 +33290,7 @@ function buildOrganicEvidence(db, projectName, periodDays) {
33228
33290
  observedDays: sql13`count(distinct ${gaAcquisitionDaily.date})`
33229
33291
  }).from(gaAcquisitionDaily).where(and31(
33230
33292
  eq35(gaAcquisitionDaily.projectId, project.id),
33231
- or7(...normalizedMarketingHosts.map((candidate) => sql13`lower(trim(${gaAcquisitionDaily.hostName})) = ${candidate} or lower(trim(${gaAcquisitionDaily.hostName})) like ${`%.${candidate}`}`))
33293
+ or8(...normalizedMarketingHosts.map((candidate) => sql13`lower(trim(${gaAcquisitionDaily.hostName})) = ${candidate} or lower(trim(${gaAcquisitionDaily.hostName})) like ${`%.${candidate}`}`))
33232
33294
  )).get()) : null;
33233
33295
  const gaCoverage = nativeAcquisitionActive ? nativeCoverage : legacyGaCoverage;
33234
33296
  const asOfDate = gscCoverage?.endDate ?? gaCoverage?.endDate ?? null;
@@ -34570,7 +34632,7 @@ async function resultsExportRoutes(app) {
34570
34632
  }
34571
34633
 
34572
34634
  // ../api-routes/src/composites.ts
34573
- import { eq as eq39, and as and35, desc as desc17, sql as sql14, like as like3, or as or8, inArray as inArray18 } from "drizzle-orm";
34635
+ import { eq as eq39, and as and35, desc as desc17, sql as sql14, like as like3, or as or9, inArray as inArray18 } from "drizzle-orm";
34574
34636
  var TOP_INSIGHT_LIMIT = 5;
34575
34637
  var SEARCH_HIT_HARD_LIMIT = 50;
34576
34638
  var SEARCH_SNIPPET_RADIUS = 80;
@@ -34767,7 +34829,7 @@ async function compositeRoutes(app) {
34767
34829
  }).from(querySnapshots).innerJoin(queries, eq39(querySnapshots.queryId, queries.id)).where(
34768
34830
  and35(
34769
34831
  eq39(queries.projectId, project.id),
34770
- or8(
34832
+ or9(
34771
34833
  sql14`${querySnapshots.answerText} LIKE ${pattern} ESCAPE '\\'`,
34772
34834
  sql14`${querySnapshots.citedDomains} LIKE ${pattern} ESCAPE '\\'`,
34773
34835
  sql14`${querySnapshots.citedUrls} LIKE ${pattern} ESCAPE '\\'`,
@@ -34779,7 +34841,7 @@ async function compositeRoutes(app) {
34779
34841
  const insightMatches = app.db.select().from(insights).where(
34780
34842
  and35(
34781
34843
  eq39(insights.projectId, project.id),
34782
- or8(
34844
+ or9(
34783
34845
  like3(insights.title, pattern),
34784
34846
  like3(insights.query, pattern),
34785
34847
  sql14`${insights.recommendation} LIKE ${pattern} ESCAPE '\\'`,
@@ -42611,7 +42673,7 @@ var canonryLocalRouteCatalog = [
42611
42673
  method: "get",
42612
42674
  path: "/api/v1/projects/{name}/agent/transcript",
42613
42675
  summary: "Get the rolling Aero transcript for this project",
42614
- description: "Returns the full message history of the project-scoped Aero session plus the persisted model provider/id and last-updated timestamp. Empty messages array when the project has no session yet.",
42676
+ description: "Returns the full message history of the project-scoped Aero session plus the persisted model provider/id and last-updated timestamp. Empty messages array when the project has no session yet. Administrator-only: a signed-in viewer, and any API key narrower than the install (read-only, or scoped to a single project), are refused with 403. There is one Aero session per project, so this is the operator conversation rather than metadata about it.",
42615
42677
  tags: ["agent"],
42616
42678
  parameters: [nameParameter],
42617
42679
  responses: {
@@ -42624,7 +42686,7 @@ var canonryLocalRouteCatalog = [
42624
42686
  method: "delete",
42625
42687
  path: "/api/v1/projects/{name}/agent/transcript",
42626
42688
  summary: "Reset the Aero transcript + queued follow-ups",
42627
- description: "Evicts any live Agent instance, clears the persisted messages and follow_up_queue. A subsequent prompt starts a fresh session.",
42689
+ description: "Evicts any live Agent instance, clears the persisted messages and follow_up_queue. A subsequent prompt starts a fresh session. Administrator-only: a signed-in viewer, and any API key narrower than the install, are refused with 403.",
42628
42690
  tags: ["agent"],
42629
42691
  parameters: [nameParameter],
42630
42692
  responses: {
@@ -42637,7 +42699,7 @@ var canonryLocalRouteCatalog = [
42637
42699
  method: "get",
42638
42700
  path: "/api/v1/projects/{name}/agent/memory",
42639
42701
  summary: "List durable Aero memory entries for a project",
42640
- description: "Returns the project-scoped agent_memory rows newest-first. Includes both operator-authored notes (source `user`/`aero`) and LLM-authored compaction summaries (source `compaction`, key prefix `compaction:`). The N most-recent rows are also injected into the system prompt at every new session start.",
42702
+ description: "Returns the project-scoped agent_memory rows newest-first. Includes both operator-authored notes (source `user`/`aero`) and LLM-authored compaction summaries (source `compaction`, key prefix `compaction:`). The N most-recent rows are also injected into the system prompt at every new session start. Administrator-only: a signed-in viewer, and any API key narrower than the install, are refused with 403.",
42641
42703
  tags: ["agent"],
42642
42704
  parameters: [nameParameter],
42643
42705
  responses: {
@@ -42650,7 +42712,7 @@ var canonryLocalRouteCatalog = [
42650
42712
  method: "put",
42651
42713
  path: "/api/v1/projects/{name}/agent/memory",
42652
42714
  summary: "Upsert a durable Aero memory entry",
42653
- description: "Creates or replaces a project-scoped note (max 2 KB, max 128-char key). Same key replaces the prior value. Keys with the reserved `compaction:` prefix are rejected \u2014 that namespace is owned by transcript compaction.",
42715
+ description: "Creates or replaces a project-scoped note (max 2 KB, max 128-char key). Same key replaces the prior value. Keys with the reserved `compaction:` prefix are rejected \u2014 that namespace is owned by transcript compaction. Administrator-only: a signed-in viewer, and any API key narrower than the install, are refused with 403.",
42654
42716
  tags: ["agent"],
42655
42717
  parameters: [nameParameter],
42656
42718
  requestBody: {
@@ -42679,7 +42741,7 @@ var canonryLocalRouteCatalog = [
42679
42741
  method: "delete",
42680
42742
  path: "/api/v1/projects/{name}/agent/memory",
42681
42743
  summary: "Delete a durable Aero memory entry",
42682
- description: "Removes a single project-scoped note by key. Returns `status: missing` (non-error) when the key never existed. Keys with the reserved `compaction:` prefix are rejected \u2014 those notes are pruned automatically.",
42744
+ description: "Removes a single project-scoped note by key. Returns `status: missing` (non-error) when the key never existed. Keys with the reserved `compaction:` prefix are rejected \u2014 those notes are pruned automatically. Administrator-only: a signed-in viewer, and any API key narrower than the install, are refused with 403.",
42683
42745
  tags: ["agent"],
42684
42746
  parameters: [nameParameter],
42685
42747
  requestBody: {
@@ -42707,7 +42769,7 @@ var canonryLocalRouteCatalog = [
42707
42769
  method: "get",
42708
42770
  path: "/api/v1/projects/{name}/agent/providers",
42709
42771
  summary: "List the LLM providers Aero can route to",
42710
- description: "Returns every provider Aero knows about with its default model, whether a usable API key is configured, and where the key resolved from (`config` | `env`). `defaultProvider` is the one Aero auto-picks when a caller omits `provider` on the prompt endpoint. Path is project-scoped for auth symmetry; the response does not vary per project today.",
42772
+ description: "Returns every provider Aero knows about with its default model, whether a usable API key is configured, and where the key resolved from (`config` | `env`). `defaultProvider` is the one Aero auto-picks when a caller omits `provider` on the prompt endpoint. Path is project-scoped for auth symmetry; the response does not vary per project today. Administrator-only: a signed-in viewer, and any API key narrower than the install, are refused with 403, because each entry names a default model and which providers are configured is itself administrator knowledge.",
42711
42773
  tags: ["agent"],
42712
42774
  parameters: [nameParameter],
42713
42775
  responses: {
@@ -42719,7 +42781,7 @@ var canonryLocalRouteCatalog = [
42719
42781
  method: "post",
42720
42782
  path: "/api/v1/projects/{name}/agent/prompt",
42721
42783
  summary: "Send a prompt to Aero and stream events back as SSE",
42722
- description: 'Posts a prompt into the project\'s Aero session and streams `AgentEvent` frames as `text/event-stream`. Each frame is `data: <JSON>\\n\\n`. The server brackets the stream with `{"type":"stream_open"}` and `{"type":"stream_close"}` control frames; `{"type":"error","message":"..."}` surfaces in-stream failures without collapsing the stream. Returns 409 `AGENT_BUSY` if another turn is already in flight for this project. Body field `scope` accepts "all" | "read-only"; omitted defaults to "read-only" (safe dashboard surface). Body field `profile` accepts "default" | "ads-operator"; omitted keeps the default full Canonry operator surface. The CLI passes "all" to keep write tools available.',
42784
+ description: 'Posts a prompt into the project\'s Aero session and streams `AgentEvent` frames as `text/event-stream`. Each frame is `data: <JSON>\\n\\n`. The server brackets the stream with `{"type":"stream_open"}` and `{"type":"stream_close"}` control frames; `{"type":"error","message":"..."}` surfaces in-stream failures without collapsing the stream. Returns 409 `AGENT_BUSY` if another turn is already in flight for this project. Body field `scope` accepts "all" | "read-only"; omitted defaults to "read-only" (safe dashboard surface). Body field `profile` accepts "default" | "ads-operator"; omitted keeps the default full Canonry operator surface. The CLI passes "all" to keep write tools available. Administrator-only: a signed-in viewer, and any API key narrower than the install, are refused with 403. Tools execute with the install root key, so this route is an operator surface regardless of the scope requested.',
42723
42785
  tags: ["agent"],
42724
42786
  parameters: [nameParameter],
42725
42787
  requestBody: {
@@ -47297,7 +47359,7 @@ async function googleRoutes(app, opts) {
47297
47359
 
47298
47360
  // ../api-routes/src/google-marketing.ts
47299
47361
  import crypto33 from "crypto";
47300
- import { and as and38, desc as desc20, eq as eq45, isNull as isNull6, lt as lt6, ne as ne7, or as or9, sql as sql16 } from "drizzle-orm";
47362
+ import { and as and38, desc as desc20, eq as eq45, isNull as isNull6, lt as lt7, ne as ne6, or as or10, sql as sql16 } from "drizzle-orm";
47301
47363
  import { z as z4 } from "zod";
47302
47364
  var snapshotPageQuerySchema = z4.object({
47303
47365
  limit: z4.coerce.number().int().min(1).max(GOOGLE_MARKETING_STORED_SNAPSHOT_PAGE_MAX).optional(),
@@ -48512,7 +48574,7 @@ async function googleMarketingRoutes(app, opts) {
48512
48574
  const existing = tx.select().from(runs).where(and38(
48513
48575
  eq45(runs.projectId, project.id),
48514
48576
  eq45(runs.kind, RunKinds["google-ads-sync"]),
48515
- or9(eq45(runs.status, RunStatuses.queued), eq45(runs.status, RunStatuses.running))
48577
+ or10(eq45(runs.status, RunStatuses.queued), eq45(runs.status, RunStatuses.running))
48516
48578
  )).orderBy(desc20(runs.createdAt)).get();
48517
48579
  if (existing) {
48518
48580
  run = existing;
@@ -48548,7 +48610,7 @@ async function googleMarketingRoutes(app, opts) {
48548
48610
  const existing = tx.select().from(runs).where(and38(
48549
48611
  eq45(runs.projectId, project.id),
48550
48612
  eq45(runs.kind, RunKinds["gtm-sync"]),
48551
- or9(eq45(runs.status, RunStatuses.queued), eq45(runs.status, RunStatuses.running))
48613
+ or10(eq45(runs.status, RunStatuses.queued), eq45(runs.status, RunStatuses.running))
48552
48614
  )).orderBy(desc20(runs.createdAt)).get();
48553
48615
  if (existing) {
48554
48616
  run = existing;
@@ -48578,9 +48640,9 @@ async function googleMarketingRoutes(app, opts) {
48578
48640
  const cursor = decodeSnapshotCursor(query.cursor);
48579
48641
  const conditions = [eq45(googleAdsRawSnapshots.projectId, project.id)];
48580
48642
  if (cursor) {
48581
- conditions.push(or9(
48582
- lt6(googleAdsRawSnapshots.capturedAt, cursor.capturedAt),
48583
- and38(eq45(googleAdsRawSnapshots.capturedAt, cursor.capturedAt), lt6(googleAdsRawSnapshots.id, cursor.id))
48643
+ conditions.push(or10(
48644
+ lt7(googleAdsRawSnapshots.capturedAt, cursor.capturedAt),
48645
+ and38(eq45(googleAdsRawSnapshots.capturedAt, cursor.capturedAt), lt7(googleAdsRawSnapshots.id, cursor.id))
48584
48646
  ));
48585
48647
  }
48586
48648
  const limit = query.limit ?? 50;
@@ -48610,9 +48672,9 @@ async function googleMarketingRoutes(app, opts) {
48610
48672
  const cursor = decodeSnapshotCursor(query.cursor);
48611
48673
  const conditions = [eq45(gtmRawSnapshots.projectId, project.id)];
48612
48674
  if (cursor) {
48613
- conditions.push(or9(
48614
- lt6(gtmRawSnapshots.capturedAt, cursor.capturedAt),
48615
- and38(eq45(gtmRawSnapshots.capturedAt, cursor.capturedAt), lt6(gtmRawSnapshots.id, cursor.id))
48675
+ conditions.push(or10(
48676
+ lt7(gtmRawSnapshots.capturedAt, cursor.capturedAt),
48677
+ and38(eq45(gtmRawSnapshots.capturedAt, cursor.capturedAt), lt7(gtmRawSnapshots.id, cursor.id))
48616
48678
  ));
48617
48679
  }
48618
48680
  const limit = query.limit ?? 50;
@@ -48798,7 +48860,7 @@ async function googleMarketingRoutes(app, opts) {
48798
48860
  const duplicate = app.db.select({ id: conversionTrackingContracts.id }).from(conversionTrackingContracts).where(and38(
48799
48861
  eq45(conversionTrackingContracts.projectId, project.id),
48800
48862
  eq45(conversionTrackingContracts.name, body.name),
48801
- ne7(conversionTrackingContracts.id, existing.id)
48863
+ ne6(conversionTrackingContracts.id, existing.id)
48802
48864
  )).get();
48803
48865
  if (duplicate) throw alreadyExists("Conversion tracking contract", body.name);
48804
48866
  const updatedAt = (/* @__PURE__ */ new Date()).toISOString();
@@ -48887,7 +48949,7 @@ async function googleMarketingRoutes(app, opts) {
48887
48949
 
48888
48950
  // ../api-routes/src/ads.ts
48889
48951
  import crypto36 from "crypto";
48890
- import { eq as eq47, and as and40, asc as asc9, desc as desc21, gt as gt3, gte as gte12, lt as lt7, lte as lte11, ne as ne8, inArray as inArray20, or as or11, isNull as isNull8, isNotNull as isNotNull4, sql as sql18 } from "drizzle-orm";
48952
+ import { eq as eq47, and as and40, asc as asc9, desc as desc21, gt as gt3, gte as gte12, lt as lt8, lte as lte11, ne as ne7, inArray as inArray20, or as or12, isNull as isNull8, isNotNull as isNotNull4, sql as sql18 } from "drizzle-orm";
48891
48953
 
48892
48954
  // ../api-routes/src/ads-activation.ts
48893
48955
  import crypto34 from "crypto";
@@ -50273,7 +50335,7 @@ async function executeApprovedAdsActivation(dependencies, request) {
50273
50335
 
50274
50336
  // ../api-routes/src/ads-activation-routes.ts
50275
50337
  import crypto35 from "crypto";
50276
- import { and as and39, asc as asc8, eq as eq46, gt as gt2, isNotNull as isNotNull3, isNull as isNull7, lte as lte10, or as or10, sql as sql17 } from "drizzle-orm";
50338
+ import { and as and39, asc as asc8, eq as eq46, gt as gt2, isNotNull as isNotNull3, isNull as isNull7, lte as lte10, or as or11, sql as sql17 } from "drizzle-orm";
50277
50339
  var MAX_ACTIVATION_GRANT_LIFETIME_MS = 24 * 60 * 60 * 1e3;
50278
50340
  var ACTIVATION_LEASE_MS = 5 * 6e4;
50279
50341
  var ACTIVATION_REVOCATION_SETTLEMENT_MS = ACTIVATION_LEASE_MS;
@@ -50399,7 +50461,7 @@ function createActivationStore(app, request) {
50399
50461
  }).where(and39(
50400
50462
  eq46(adsOperations.id, existing.id),
50401
50463
  eq46(adsOperations.state, AdsOperationStates.pending),
50402
- or10(
50464
+ or11(
50403
50465
  isNull7(adsOperations.leaseOwner),
50404
50466
  eq46(adsOperations.leaseOwner, input.leaseOwner),
50405
50467
  isNull7(adsOperations.leaseExpiresAt),
@@ -50817,29 +50879,29 @@ async function executeActivationGrant(app, opts, request, project, executorApiKe
50817
50879
  return activationResponse(opts, result.grant, result.operation);
50818
50880
  }
50819
50881
  function selectWatchdogOperations(app, nowIso, batchSize, allowLiveRevocationLease) {
50820
- const leaseAvailable = or10(
50882
+ const leaseAvailable = or11(
50821
50883
  isNull7(adsOperations.leaseOwner),
50822
50884
  isNull7(adsOperations.leaseExpiresAt),
50823
50885
  lte10(adsOperations.leaseExpiresAt, nowIso)
50824
50886
  );
50825
- const campaignHasMutationEvidence = or10(
50887
+ const campaignHasMutationEvidence = or11(
50826
50888
  eq46(adsOperationSteps.state, AdsOperationStepStates.executing),
50827
50889
  eq46(adsOperationSteps.state, AdsOperationStepStates.active),
50828
50890
  eq46(adsOperationSteps.state, AdsOperationStepStates.rollback_executing),
50829
50891
  eq46(adsOperationSteps.state, AdsOperationStepStates.rollback_failed),
50830
50892
  eq46(adsOperationSteps.state, AdsOperationStepStates.unknown)
50831
50893
  );
50832
- const eligibleState = allowLiveRevocationLease ? or10(
50894
+ const eligibleState = allowLiveRevocationLease ? or11(
50833
50895
  eq46(adsOperations.state, AdsOperationStates.unknown),
50834
50896
  and39(
50835
50897
  eq46(adsOperations.state, AdsOperationStates.pending),
50836
- or10(
50898
+ or11(
50837
50899
  isNotNull3(adsActivationGrants.revocationRequestedAt),
50838
50900
  and39(leaseAvailable, campaignHasMutationEvidence)
50839
50901
  )
50840
50902
  )
50841
50903
  ) : and39(
50842
- or10(
50904
+ or11(
50843
50905
  eq46(adsOperations.state, AdsOperationStates.unknown),
50844
50906
  eq46(adsOperations.state, AdsOperationStates.pending)
50845
50907
  ),
@@ -50983,7 +51045,7 @@ async function containUrgentAdsActivationCampaignsOnce(app, opts) {
50983
51045
  lastReconciledAt: (/* @__PURE__ */ new Date()).toISOString()
50984
51046
  }).where(and39(
50985
51047
  eq46(adsOperations.id, operation.id),
50986
- or10(
51048
+ or11(
50987
51049
  eq46(adsOperations.state, AdsOperationStates.unknown),
50988
51050
  eq46(adsOperations.state, AdsOperationStates.pending)
50989
51051
  )
@@ -51009,11 +51071,11 @@ async function sweepStaleAdsActivationsOnce(app, opts) {
51009
51071
  updatedAt: nowIso
51010
51072
  }).where(and39(
51011
51073
  eq46(adsOperations.id, operation.id),
51012
- or10(
51074
+ or11(
51013
51075
  eq46(adsOperations.state, AdsOperationStates.unknown),
51014
51076
  and39(
51015
51077
  eq46(adsOperations.state, AdsOperationStates.pending),
51016
- or10(
51078
+ or11(
51017
51079
  isNull7(adsOperations.leaseOwner),
51018
51080
  isNull7(adsOperations.leaseExpiresAt),
51019
51081
  lte10(adsOperations.leaseExpiresAt, nowIso)
@@ -51046,11 +51108,11 @@ async function sweepStaleAdsActivationsOnce(app, opts) {
51046
51108
  const failedAt = (/* @__PURE__ */ new Date()).toISOString();
51047
51109
  app.db.update(adsOperations).set({ updatedAt: failedAt }).where(and39(
51048
51110
  eq46(adsOperations.id, operation.id),
51049
- or10(
51111
+ or11(
51050
51112
  eq46(adsOperations.state, AdsOperationStates.unknown),
51051
51113
  and39(
51052
51114
  eq46(adsOperations.state, AdsOperationStates.pending),
51053
- or10(
51115
+ or11(
51054
51116
  isNull7(adsOperations.leaseOwner),
51055
51117
  isNull7(adsOperations.leaseExpiresAt),
51056
51118
  lte10(adsOperations.leaseExpiresAt, failedAt)
@@ -52236,7 +52298,7 @@ function claimOperationForReconciliation(app, row, leaseOwner, now, leaseMs, pol
52236
52298
  enforceBackoff ? lte11(adsOperations.updatedAt, unknownCutoff) : void 0
52237
52299
  ) : and40(
52238
52300
  eq47(adsOperations.state, AdsOperationStates.reconciling),
52239
- or11(isNull8(adsOperations.leaseExpiresAt), lte11(adsOperations.leaseExpiresAt, nowIso))
52301
+ or12(isNull8(adsOperations.leaseExpiresAt), lte11(adsOperations.leaseExpiresAt, nowIso))
52240
52302
  );
52241
52303
  const claimed = app.db.update(adsOperations).set({
52242
52304
  state: AdsOperationStates.reconciling,
@@ -52248,7 +52310,7 @@ function claimOperationForReconciliation(app, row, leaseOwner, now, leaseMs, pol
52248
52310
  eq47(adsOperations.id, row.id),
52249
52311
  eq47(adsOperations.projectId, row.projectId),
52250
52312
  eq47(adsOperations.reconcileAttempts, row.reconcileAttempts),
52251
- lt7(adsOperations.reconcileAttempts, policy.maxAttempts),
52313
+ lt8(adsOperations.reconcileAttempts, policy.maxAttempts),
52252
52314
  claimableState
52253
52315
  )).returning({ id: adsOperations.id }).all();
52254
52316
  if (claimed.length === 0) return void 0;
@@ -52558,28 +52620,28 @@ async function sweepAdsOperationsOnce(app, opts, config) {
52558
52620
  );
52559
52621
  }
52560
52622
  );
52561
- const retryableStateWhere = or11(
52623
+ const retryableStateWhere = or12(
52562
52624
  and40(
52563
52625
  eq47(adsOperations.state, AdsOperationStates.pending),
52564
52626
  lte11(adsOperations.updatedAt, pendingCutoff)
52565
52627
  ),
52566
52628
  and40(
52567
52629
  eq47(adsOperations.state, AdsOperationStates.unknown),
52568
- or11(...unknownRetryWindows)
52630
+ or12(...unknownRetryWindows)
52569
52631
  ),
52570
52632
  and40(
52571
52633
  eq47(adsOperations.state, AdsOperationStates.reconciling),
52572
- or11(isNull8(adsOperations.leaseExpiresAt), lte11(adsOperations.leaseExpiresAt, nowIso))
52634
+ or12(isNull8(adsOperations.leaseExpiresAt), lte11(adsOperations.leaseExpiresAt, nowIso))
52573
52635
  )
52574
52636
  );
52575
52637
  const staleReconcileWhere = and40(
52576
- lt7(adsOperations.reconcileAttempts, config.policy.maxAttempts),
52577
- ne8(adsOperations.kind, AdsOperationKinds.campaign_tree_activate),
52578
- or11(
52638
+ lt8(adsOperations.reconcileAttempts, config.policy.maxAttempts),
52639
+ ne7(adsOperations.kind, AdsOperationKinds.campaign_tree_activate),
52640
+ or12(
52579
52641
  eq47(adsOperations.reconcileStrategy, AdsReconcileStrategies.known_entity),
52580
52642
  and40(
52581
52643
  eq47(adsOperations.reconcileStrategy, AdsReconcileStrategies.create_fingerprint),
52582
- or11(
52644
+ or12(
52583
52645
  isNotNull4(adsOperations.entityId),
52584
52646
  inArray20(adsOperations.state, [
52585
52647
  AdsOperationStates.pending,
@@ -52591,9 +52653,9 @@ async function sweepAdsOperationsOnce(app, opts, config) {
52591
52653
  retryableStateWhere
52592
52654
  );
52593
52655
  const exhaustedRows = app.db.select().from(adsOperations).where(and40(
52594
- ne8(adsOperations.kind, AdsOperationKinds.campaign_tree_activate),
52656
+ ne7(adsOperations.kind, AdsOperationKinds.campaign_tree_activate),
52595
52657
  gte12(adsOperations.reconcileAttempts, config.policy.maxAttempts),
52596
- or11(
52658
+ or12(
52597
52659
  and40(
52598
52660
  eq47(adsOperations.state, AdsOperationStates.pending),
52599
52661
  lte11(adsOperations.updatedAt, pendingCutoff)
@@ -52601,12 +52663,12 @@ async function sweepAdsOperationsOnce(app, opts, config) {
52601
52663
  eq47(adsOperations.state, AdsOperationStates.unknown),
52602
52664
  and40(
52603
52665
  eq47(adsOperations.state, AdsOperationStates.reconciling),
52604
- or11(isNull8(adsOperations.leaseExpiresAt), lte11(adsOperations.leaseExpiresAt, nowIso))
52666
+ or12(isNull8(adsOperations.leaseExpiresAt), lte11(adsOperations.leaseExpiresAt, nowIso))
52605
52667
  )
52606
52668
  ),
52607
- or11(
52669
+ or12(
52608
52670
  isNull8(adsOperations.errorCode),
52609
- ne8(adsOperations.errorCode, ADS_RECONCILIATION_QUARANTINED)
52671
+ ne7(adsOperations.errorCode, ADS_RECONCILIATION_QUARANTINED)
52610
52672
  )
52611
52673
  )).orderBy(asc9(adsOperations.updatedAt)).limit(config.batchSize).all();
52612
52674
  for (const row of exhaustedRows) {
@@ -52952,7 +53014,7 @@ async function adsRoutes(app, opts) {
52952
53014
  const rows = app.db.select().from(adsOperations).where(and40(
52953
53015
  eq47(adsOperations.projectId, project.id),
52954
53016
  inArray20(adsOperations.state, parsed.data.state),
52955
- cursor ? or11(
53017
+ cursor ? or12(
52956
53018
  gt3(adsOperations.createdAt, cursor.createdAt),
52957
53019
  and40(
52958
53020
  eq47(adsOperations.createdAt, cursor.createdAt),
@@ -58084,7 +58146,7 @@ function pruneCachedRelease(release, opts = {}) {
58084
58146
  }
58085
58147
 
58086
58148
  // ../api-routes/src/backlinks-filter.ts
58087
- import { and as and44, ne as ne9, notLike } from "drizzle-orm";
58149
+ import { and as and44, ne as ne8, notLike } from "drizzle-orm";
58088
58150
  var BACKLINK_FILTER_PATTERNS = [
58089
58151
  "*.google.com",
58090
58152
  "*.googleusercontent.com",
@@ -58101,10 +58163,10 @@ function backlinkCrawlerExclusionClause() {
58101
58163
  for (const pattern of BACKLINK_FILTER_PATTERNS) {
58102
58164
  if (pattern.startsWith("*.")) {
58103
58165
  const suffix = pattern.slice(2);
58104
- conditions.push(ne9(backlinkDomains.linkingDomain, suffix));
58166
+ conditions.push(ne8(backlinkDomains.linkingDomain, suffix));
58105
58167
  conditions.push(notLike(backlinkDomains.linkingDomain, `%.${suffix}`));
58106
58168
  } else {
58107
- conditions.push(ne9(backlinkDomains.linkingDomain, pattern));
58169
+ conditions.push(ne8(backlinkDomains.linkingDomain, pattern));
58108
58170
  }
58109
58171
  }
58110
58172
  const combined = and44(...conditions);
@@ -75138,10 +75200,10 @@ async function ackCloudflareQueueMessages(client, options) {
75138
75200
 
75139
75201
  // ../api-routes/src/traffic-event-ingest.ts
75140
75202
  import crypto43 from "crypto";
75141
- import { and as and47, eq as eq53, gte as gte13, lt as lt9, lte as lte12, sql as sql22 } from "drizzle-orm";
75203
+ import { and as and47, eq as eq53, gte as gte13, lt as lt10, lte as lte12, sql as sql22 } from "drizzle-orm";
75142
75204
 
75143
75205
  // ../api-routes/src/raw-event-sample-retention.ts
75144
- import { and as and46, eq as eq52, lt as lt8, sql as sql21 } from "drizzle-orm";
75206
+ import { and as and46, eq as eq52, lt as lt9, sql as sql21 } from "drizzle-orm";
75145
75207
  var RAW_EVENT_SAMPLE_RETENTION_MS = 30 * 24 * 60 * 6e4;
75146
75208
  var RAW_EVENT_SAMPLE_RETENTION_SWEEP_INTERVAL_MS = 24 * 60 * 6e4;
75147
75209
  var CANONICAL_UTC_TIMESTAMP_GLOB = "[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T[0-9][0-9]:[0-9][0-9]:[0-9][0-9].[0-9][0-9][0-9]Z";
@@ -75156,7 +75218,7 @@ function deleteExpiredRawEventSamples(db, cutoff, sourceId) {
75156
75218
  db.delete(rawEventSamples).where(and46(
75157
75219
  sourceScope,
75158
75220
  sql21`${rawEventSamples.ts} GLOB ${CANONICAL_UTC_TIMESTAMP_GLOB}`,
75159
- lt8(rawEventSamples.ts, cutoff)
75221
+ lt9(rawEventSamples.ts, cutoff)
75160
75222
  )).run();
75161
75223
  db.delete(rawEventSamples).where(and46(
75162
75224
  sourceScope,
@@ -75418,7 +75480,7 @@ function writeTrafficEventBatch(opts) {
75418
75480
  const existing = tx.select({ total: sql22`COUNT(*)` }).from(rawEventSamples).where(and47(
75419
75481
  eq53(rawEventSamples.sourceId, opts.sourceId),
75420
75482
  gte13(rawEventSamples.ts, hour.start),
75421
- lt9(rawEventSamples.ts, hour.end)
75483
+ lt10(rawEventSamples.ts, hour.end)
75422
75484
  )).get();
75423
75485
  remaining = Math.max(0, hourlySampleLimit - Number(existing?.total ?? 0));
75424
75486
  }
@@ -75475,7 +75537,7 @@ function writeTrafficEventBatch(opts) {
75475
75537
  }
75476
75538
 
75477
75539
  // ../api-routes/src/traffic-sync-lease.ts
75478
- import { and as and48, eq as eq54, isNull as isNull9, lte as lte13, or as or12 } from "drizzle-orm";
75540
+ import { and as and48, eq as eq54, isNull as isNull9, lte as lte13, or as or13 } from "drizzle-orm";
75479
75541
  function leaseExpiresAt(now, ttlMs) {
75480
75542
  const nowMs = Date.parse(now);
75481
75543
  if (!Number.isFinite(nowMs)) throw new RangeError("Traffic sync lease now must be an ISO instant");
@@ -75490,7 +75552,7 @@ function tryClaimTrafficSyncLease(input) {
75490
75552
  syncLeaseExpiresAt: expiresAt
75491
75553
  }).where(and48(
75492
75554
  eq54(trafficSources.id, input.sourceId),
75493
- or12(
75555
+ or13(
75494
75556
  isNull9(trafficSources.syncLeaseOwner),
75495
75557
  lte13(trafficSources.syncLeaseExpiresAt, input.now),
75496
75558
  eq54(trafficSources.syncLeaseOwner, input.owner)
@@ -80678,6 +80740,14 @@ var agentProvidersConfiguredCheck = {
80678
80740
  scope: CheckScopes.global,
80679
80741
  title: "Agent provider keys",
80680
80742
  run: (ctx) => {
80743
+ if (ctx.callerIsInstanceAdministrator === false) {
80744
+ return {
80745
+ status: CheckStatuses.skipped,
80746
+ code: "agent-providers.restricted",
80747
+ summary: "Agent provider configuration is visible to administrators only.",
80748
+ remediation: null
80749
+ };
80750
+ }
80681
80751
  const summary2 = ctx.getAgentProviderSummary?.();
80682
80752
  if (!summary2) {
80683
80753
  return {
@@ -80794,7 +80864,7 @@ var RUNTIME_STATE_CHECKS = [
80794
80864
  ];
80795
80865
 
80796
80866
  // ../api-routes/src/doctor/checks/traffic-source.ts
80797
- import { and as and53, eq as eq62, gte as gte15, inArray as inArray23, ne as ne10, sql as sql24 } from "drizzle-orm";
80867
+ import { and as and53, eq as eq62, gte as gte15, inArray as inArray23, ne as ne9, sql as sql24 } from "drizzle-orm";
80798
80868
  var RECENT_DATA_WARN_DAYS = 7;
80799
80869
  var RECENT_DATA_FAIL_DAYS = 30;
80800
80870
  function isCloudflareDirectPush(source) {
@@ -80835,7 +80905,7 @@ function loadProbes(ctx) {
80835
80905
  const rows = ctx.db.select().from(trafficSources).where(
80836
80906
  and53(
80837
80907
  eq62(trafficSources.projectId, ctx.project.id),
80838
- ne10(trafficSources.status, TrafficSourceStatuses.archived)
80908
+ ne9(trafficSources.status, TrafficSourceStatuses.archived)
80839
80909
  )
80840
80910
  ).all();
80841
80911
  return rows.map((r) => ({
@@ -81944,6 +82014,7 @@ async function doctorRoutes(app, opts) {
81944
82014
  redirectUri,
81945
82015
  providerSummary: opts.providerSummary,
81946
82016
  getAgentProviderSummary: opts.getAgentProviderSummary,
82017
+ callerIsInstanceAdministrator: isInstanceAdministrator(request),
81947
82018
  trafficSourceValidators: opts.trafficSourceValidators,
81948
82019
  runtimeStatePaths: opts.runtimeStatePaths,
81949
82020
  bundledSkills: opts.bundledSkills,
@@ -81974,6 +82045,7 @@ async function doctorRoutes(app, opts) {
81974
82045
  redirectUri,
81975
82046
  providerSummary: opts.providerSummary,
81976
82047
  getAgentProviderSummary: opts.getAgentProviderSummary,
82048
+ callerIsInstanceAdministrator: isInstanceAdministrator(request),
81977
82049
  trafficSourceValidators: opts.trafficSourceValidators,
81978
82050
  runtimeStatePaths: opts.runtimeStatePaths,
81979
82051
  bundledSkills: opts.bundledSkills,
@@ -81987,7 +82059,7 @@ async function doctorRoutes(app, opts) {
81987
82059
 
81988
82060
  // ../api-routes/src/discovery/routes.ts
81989
82061
  import crypto46 from "crypto";
81990
- import { and as and54, desc as desc27, eq as eq63, gte as gte16, inArray as inArray24, isNull as isNull10, or as or13 } from "drizzle-orm";
82062
+ import { and as and54, desc as desc27, eq as eq63, gte as gte16, inArray as inArray24, isNull as isNull10, or as or14 } from "drizzle-orm";
81991
82063
  var MAX_INFLIGHT_DISCOVERY_AGE_MS = 2 * 60 * 60 * 1e3;
81992
82064
  async function discoveryRoutes(app, opts) {
81993
82065
  app.post("/projects/:name/discover/run", async (request, reply) => {
@@ -82037,7 +82109,7 @@ async function discoveryRoutes(app, opts) {
82037
82109
  // locations a NULL row's subset is unknowable, so it conservatively
82038
82110
  // never reuses — a one-time, bounded (2h window) non-reuse after
82039
82111
  // upgrade, never a wrong reuse.
82040
- locations.length === 0 ? or13(isNull10(discoverySessions.locations), eq63(discoverySessions.locations, locations)) : eq63(discoverySessions.locations, locations),
82112
+ locations.length === 0 ? or14(isNull10(discoverySessions.locations), eq63(discoverySessions.locations, locations)) : eq63(discoverySessions.locations, locations),
82041
82113
  // Seed provider set is identity: a different phrasing distribution
82042
82114
  // must never reuse another set's session. Null (= Gemini-only
82043
82115
  // default, including explicit ['gemini']) matches legacy rows.
@@ -82667,7 +82739,7 @@ function dedupeStrings(input) {
82667
82739
 
82668
82740
  // ../api-routes/src/technical-aeo.ts
82669
82741
  import crypto48 from "crypto";
82670
- import { and as and55, asc as asc11, count as count2, desc as desc28, eq as eq65, inArray as inArray25, isNotNull as isNotNull5, isNull as isNull11, lt as lt10, or as or14, sql as sql25 } from "drizzle-orm";
82742
+ import { and as and55, asc as asc11, count as count2, desc as desc28, eq as eq65, inArray as inArray25, isNotNull as isNotNull5, isNull as isNull11, lt as lt11, or as or15, sql as sql25 } from "drizzle-orm";
82671
82743
  import { alias } from "drizzle-orm/sqlite-core";
82672
82744
  var FETCHED_SITE_CRAWL_STATES = /* @__PURE__ */ new Set([
82673
82745
  ...SiteCrawlFetchedStates,
@@ -83063,7 +83135,7 @@ async function technicalAeoRoutes(app, opts) {
83063
83135
  return emptyScore(project.name);
83064
83136
  }
83065
83137
  const snap = latest.snap;
83066
- const previous = app.db.select({ snap: siteAuditSnapshots }).from(siteAuditSnapshots).innerJoin(runs, eq65(siteAuditSnapshots.runId, runs.id)).where(and55(...baseFilters, lt10(siteAuditSnapshots.createdAt, snap.createdAt))).orderBy(desc28(siteAuditSnapshots.createdAt)).limit(1).get()?.snap ?? null;
83138
+ const previous = app.db.select({ snap: siteAuditSnapshots }).from(siteAuditSnapshots).innerJoin(runs, eq65(siteAuditSnapshots.runId, runs.id)).where(and55(...baseFilters, lt11(siteAuditSnapshots.createdAt, snap.createdAt))).orderBy(desc28(siteAuditSnapshots.createdAt)).limit(1).get()?.snap ?? null;
83067
83139
  const deltaScore = previous ? snap.aggregateScore - previous.aggregateScore : null;
83068
83140
  const trend = deltaScore == null ? null : deltaScore > 0 ? SiteAuditTrendDirections.up : deltaScore < 0 ? SiteAuditTrendDirections.down : SiteAuditTrendDirections.flat;
83069
83141
  return {
@@ -83309,7 +83381,7 @@ async function technicalAeoRoutes(app, opts) {
83309
83381
  eq65(siteCrawlPages.runId, siteCrawlGraphNodes.runId),
83310
83382
  eq65(siteCrawlPages.attemptId, siteCrawlGraphNodes.attemptId),
83311
83383
  eq65(siteCrawlPages.nodeKey, siteCrawlGraphNodes.nodeKey)
83312
- )).where(and55(...graphScope, lt10(siteCrawlGraphNodes.sampleRank, maxNodes))).orderBy(asc11(siteCrawlGraphNodes.sampleRank)).all();
83384
+ )).where(and55(...graphScope, lt11(siteCrawlGraphNodes.sampleRank, maxNodes))).orderBy(asc11(siteCrawlGraphNodes.sampleRank)).all();
83313
83385
  const nodes = nodeRows.map(({ indexabilityReasons, canonicalNodeKey, ...row }) => ({
83314
83386
  ...row,
83315
83387
  healthState: deriveSiteHealthState({ ...row, indexabilityReasons, canonicalNodeKey })
@@ -83327,18 +83399,18 @@ async function technicalAeoRoutes(app, opts) {
83327
83399
  eq65(graphSourceNode.runId, siteCrawlGraphEdges.runId),
83328
83400
  eq65(graphSourceNode.attemptId, siteCrawlGraphEdges.attemptId),
83329
83401
  eq65(graphSourceNode.nodeKey, siteCrawlGraphEdges.sourceNodeKey),
83330
- lt10(graphSourceNode.sampleRank, maxNodes)
83402
+ lt11(graphSourceNode.sampleRank, maxNodes)
83331
83403
  )).innerJoin(graphTargetNode, and55(
83332
83404
  eq65(graphTargetNode.projectId, siteCrawlGraphEdges.projectId),
83333
83405
  eq65(graphTargetNode.runId, siteCrawlGraphEdges.runId),
83334
83406
  eq65(graphTargetNode.attemptId, siteCrawlGraphEdges.attemptId),
83335
83407
  eq65(graphTargetNode.nodeKey, siteCrawlGraphEdges.targetNodeKey),
83336
- lt10(graphTargetNode.sampleRank, maxNodes)
83408
+ lt11(graphTargetNode.sampleRank, maxNodes)
83337
83409
  )).where(and55(
83338
83410
  eq65(siteCrawlGraphEdges.projectId, project.id),
83339
83411
  eq65(siteCrawlGraphEdges.runId, snapshot.runId),
83340
83412
  eq65(siteCrawlGraphEdges.attemptId, snapshot.attemptId),
83341
- lt10(siteCrawlGraphEdges.sampleRank, maxEdges),
83413
+ lt11(siteCrawlGraphEdges.sampleRank, maxEdges),
83342
83414
  graphLinkKindFilter
83343
83415
  )).orderBy(asc11(siteCrawlGraphEdges.sampleRank)).all();
83344
83416
  const totalNodes = persistedLayout.totalNodes;
@@ -83437,7 +83509,7 @@ async function technicalAeoRoutes(app, opts) {
83437
83509
  eq65(siteCrawlEdges.attemptId, scope.attemptId),
83438
83510
  eq65(siteCrawlEdges.internal, true),
83439
83511
  isNotNull5(siteCrawlEdges.targetNodeKey),
83440
- or14(
83512
+ or15(
83441
83513
  inArray25(siteCrawlEdges.sourceNodeKey, frontier),
83442
83514
  inArray25(siteCrawlEdges.targetNodeKey, frontier)
83443
83515
  )
@@ -83684,11 +83756,11 @@ async function technicalAeoRoutes(app, opts) {
83684
83756
  if (request.query.toRunId && !afterTarget) throw notFound("Site crawl run", request.query.toRunId);
83685
83757
  const beforeTarget = request.query.fromRunId ? resolveCrawl(project.id, request.query.fromRunId) : afterTarget ? app.db.select({ snapshot: siteCrawlSnapshots, runStatus: runs.status }).from(siteCrawlSnapshots).innerJoin(runs, eq65(siteCrawlSnapshots.runId, runs.id)).where(and55(
83686
83758
  ...completeSnapshotFilters,
83687
- or14(
83688
- lt10(siteCrawlSnapshots.createdAt, afterTarget.snapshot.createdAt),
83759
+ or15(
83760
+ lt11(siteCrawlSnapshots.createdAt, afterTarget.snapshot.createdAt),
83689
83761
  and55(
83690
83762
  eq65(siteCrawlSnapshots.createdAt, afterTarget.snapshot.createdAt),
83691
- lt10(siteCrawlSnapshots.runId, afterTarget.snapshot.runId)
83763
+ lt11(siteCrawlSnapshots.runId, afterTarget.snapshot.runId)
83692
83764
  )
83693
83765
  )
83694
83766
  )).orderBy(desc28(siteCrawlSnapshots.createdAt), desc28(siteCrawlSnapshots.runId)).limit(1).get() : void 0;
@@ -84281,8 +84353,8 @@ async function technicalAeoRoutes(app, opts) {
84281
84353
  eq65(siteCrawlEdges.internal, true),
84282
84354
  linkKindFilter(linkKind)
84283
84355
  ];
84284
- const inboundMatch = request.query.nodeKey && request.query.url ? or14(eq65(siteCrawlEdges.targetNodeKey, request.query.nodeKey), eq65(siteCrawlEdges.targetUrl, request.query.url)) : request.query.nodeKey ? eq65(siteCrawlEdges.targetNodeKey, request.query.nodeKey) : eq65(siteCrawlEdges.targetUrl, request.query.url);
84285
- const outboundMatch = request.query.nodeKey && request.query.url ? or14(eq65(siteCrawlEdges.sourceNodeKey, request.query.nodeKey), eq65(siteCrawlEdges.sourceUrl, request.query.url)) : request.query.nodeKey ? eq65(siteCrawlEdges.sourceNodeKey, request.query.nodeKey) : eq65(siteCrawlEdges.sourceUrl, request.query.url);
84356
+ const inboundMatch = request.query.nodeKey && request.query.url ? or15(eq65(siteCrawlEdges.targetNodeKey, request.query.nodeKey), eq65(siteCrawlEdges.targetUrl, request.query.url)) : request.query.nodeKey ? eq65(siteCrawlEdges.targetNodeKey, request.query.nodeKey) : eq65(siteCrawlEdges.targetUrl, request.query.url);
84357
+ const outboundMatch = request.query.nodeKey && request.query.url ? or15(eq65(siteCrawlEdges.sourceNodeKey, request.query.nodeKey), eq65(siteCrawlEdges.sourceUrl, request.query.url)) : request.query.nodeKey ? eq65(siteCrawlEdges.sourceNodeKey, request.query.nodeKey) : eq65(siteCrawlEdges.sourceUrl, request.query.url);
84286
84358
  const limit = parseBoundedLimit(request.query.limit, 50, 100);
84287
84359
  const inboundRows = app.db.select().from(siteCrawlEdges).where(and55(...scope, inboundMatch)).orderBy(asc11(siteCrawlEdges.edgeKey)).limit(limit + 1).all();
84288
84360
  const outboundRows = app.db.select().from(siteCrawlEdges).where(and55(...scope, outboundMatch)).orderBy(asc11(siteCrawlEdges.edgeKey)).limit(limit + 1).all();
@@ -84484,7 +84556,7 @@ async function technicalAeoRoutes(app, opts) {
84484
84556
  }).from(siteCrawlPages).where(and55(
84485
84557
  auditedWhere,
84486
84558
  isNotNull5(siteCrawlPages.auditScore),
84487
- lt10(siteCrawlPages.auditScore, 70)
84559
+ lt11(siteCrawlPages.auditScore, 70)
84488
84560
  )).orderBy(asc11(siteCrawlPages.auditScore), asc11(siteCrawlPages.nodeKey)).limit(LIVE_PAGE_HEALTH_CANDIDATE_LIMIT).all();
84489
84561
  const examples = candidates.flatMap((candidate) => {
84490
84562
  if (candidate.auditScore == null) return [];
@@ -84577,7 +84649,7 @@ async function technicalAeoRoutes(app, opts) {
84577
84649
  // ../api-routes/src/research.ts
84578
84650
  import crypto49 from "crypto";
84579
84651
  import { z as z5 } from "zod";
84580
- import { and as and56, count as count3, desc as desc29, eq as eq66, gte as gte17, lt as lt11, or as or15, sql as sql26 } from "drizzle-orm";
84652
+ import { and as and56, count as count3, desc as desc29, eq as eq66, gte as gte17, lt as lt12, or as or16, sql as sql26 } from "drizzle-orm";
84581
84653
  var sameLocation = (a, b) => a.label === b.label && a.city === b.city && a.region === b.region && a.country === b.country && a.timezone === b.timezone;
84582
84654
  var BATCH_RECEIPT_PREFIX = "__canonry_research_batch__:";
84583
84655
  async function researchRoutes(app, opts) {
@@ -84638,7 +84710,7 @@ async function researchRoutes(app, opts) {
84638
84710
  const used = tx.select({ value: count3() }).from(researchRuns).where(and56(
84639
84711
  eq66(researchRuns.projectId, project.id),
84640
84712
  gte17(researchRuns.createdAt, start),
84641
- lt11(researchRuns.createdAt, end),
84713
+ lt12(researchRuns.createdAt, end),
84642
84714
  sql26`(json_extract(${researchRuns.initiatedBy}, '$.role') = ${UserRoles.viewer} OR json_extract(${researchRuns.initiatedBy}, '$.limited') = 1)`
84643
84715
  )).get()?.value ?? 0;
84644
84716
  if (used >= viewerDailyRunLimit) {
@@ -84693,7 +84765,7 @@ async function researchRoutes(app, opts) {
84693
84765
  const used = tx.select({ value: count3() }).from(researchRuns).where(and56(
84694
84766
  eq66(researchRuns.projectId, project.id),
84695
84767
  gte17(researchRuns.createdAt, start),
84696
- lt11(researchRuns.createdAt, end),
84768
+ lt12(researchRuns.createdAt, end),
84697
84769
  sql26`(json_extract(${researchRuns.initiatedBy}, '$.role') = ${UserRoles.viewer} OR json_extract(${researchRuns.initiatedBy}, '$.limited') = 1)`
84698
84770
  )).get()?.value ?? 0;
84699
84771
  if (used + prepared.length > viewerDailyRunLimit) throw researchDailyLimitExceeded(project.name, viewerDailyRunLimit, date);
@@ -84747,7 +84819,7 @@ async function researchRoutes(app, opts) {
84747
84819
  const cursor = request.query.cursor ? parseResearchCursor(request.query.cursor, project.id) : null;
84748
84820
  const rows = app.db.select().from(researchRuns).where(and56(
84749
84821
  eq66(researchRuns.projectId, project.id),
84750
- cursor ? or15(lt11(researchRuns.createdAt, cursor.createdAt), and56(eq66(researchRuns.createdAt, cursor.createdAt), lt11(researchRuns.id, cursor.id))) : void 0
84822
+ cursor ? or16(lt12(researchRuns.createdAt, cursor.createdAt), and56(eq66(researchRuns.createdAt, cursor.createdAt), lt12(researchRuns.id, cursor.id))) : void 0
84751
84823
  )).orderBy(desc29(researchRuns.createdAt), desc29(researchRuns.id)).limit(limit + 1).all();
84752
84824
  const runs2 = rows.slice(0, limit).map(serializeRun);
84753
84825
  const last = runs2.at(-1);
@@ -85924,11 +85996,11 @@ function analyzableRunPredicates(projectId) {
85924
85996
  eq68(runs.projectId, projectId),
85925
85997
  // Only this kind writes query_snapshots.
85926
85998
  eq68(runs.kind, RunKinds["answer-visibility"]),
85927
- or16(eq68(runs.status, RunStatuses.completed), eq68(runs.status, RunStatuses.partial)),
85999
+ or17(eq68(runs.status, RunStatuses.completed), eq68(runs.status, RunStatuses.partial)),
85928
86000
  // Defensive: RunCoordinator already skips probes before analyzeAndPersist
85929
86001
  // is called, but a future call site invoking it directly for a probe must
85930
86002
  // still not pollute the intelligence window.
85931
- ne11(runs.trigger, RunTriggers.probe)
86003
+ ne10(runs.trigger, RunTriggers.probe)
85932
86004
  ];
85933
86005
  }
85934
86006
  function readStoredGroundingSources(rawResponse) {
@@ -86514,9 +86586,9 @@ var IntelligenceService = class {
86514
86586
  and58(
86515
86587
  eq68(runs.projectId, projectId),
86516
86588
  eq68(runs.kind, RunKinds["answer-visibility"]),
86517
- or16(eq68(runs.status, "completed"), eq68(runs.status, "partial")),
86589
+ or17(eq68(runs.status, "completed"), eq68(runs.status, "partial")),
86518
86590
  // Defensive — see top of file.
86519
- ne11(runs.trigger, RunTriggers.probe)
86591
+ ne10(runs.trigger, RunTriggers.probe)
86520
86592
  )
86521
86593
  ).orderBy(desc30(runs.createdAt), desc30(runs.id)).limit((RECURRENCE_LOOKBACK_RUNS + 1) * ROWS_PER_GROUP_BUDGET).all();
86522
86594
  const recentGroups = groupRunsByCreatedAt(recentRunRows);
@@ -86727,6 +86799,7 @@ export {
86727
86799
  createUserSession,
86728
86800
  resolveUserSession,
86729
86801
  createCredentialChecker,
86802
+ requireInstanceAdministrator,
86730
86803
  hashApiKey,
86731
86804
  registerOAuthRoutes,
86732
86805
  registerOAuthAdminRoutes,