@nuxtseo/cli 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/render.js CHANGED
@@ -129,7 +129,7 @@ export function renderSearchStatus(data) {
129
129
  return `${fields([['Site', data.site], ['Search Console', 'disconnected']])}\nConnect Search Console in Site settings.`;
130
130
  }
131
131
  export function renderSearchAnalytics(data) {
132
- if (data.type === 'pages') {
132
+ if (data.view === 'pages') {
133
133
  if (data.rows.length === 0)
134
134
  return 'No Search Console Page rows match these filters.';
135
135
  return [
@@ -137,7 +137,7 @@ export function renderSearchAnalytics(data) {
137
137
  ...data.rows.map(row => `${row.clicks} clicks ${row.impressions} impressions position ${row.pos.toFixed(1)}\n ${row.url}`),
138
138
  ].join('\n');
139
139
  }
140
- if (data.type === 'keywords') {
140
+ if (data.view === 'keywords') {
141
141
  if (data.rows.length === 0)
142
142
  return 'No Search Console query rows match these filters.';
143
143
  return [
@@ -145,12 +145,275 @@ export function renderSearchAnalytics(data) {
145
145
  ...data.rows.map(row => `${row.clicks} clicks ${row.impressions} impressions position ${row.pos.toFixed(1)}\n ${row.keyword}`),
146
146
  ].join('\n');
147
147
  }
148
+ if (data.view === 'countries') {
149
+ if (data.rows.length === 0)
150
+ return 'No Search Console country rows match these filters.';
151
+ return [
152
+ `Search Console countries (${data.rows.length} of ${data.total})`,
153
+ ...data.rows.map(row => `${row.clicks} clicks ${row.impressions} impressions position ${row.pos.toFixed(1)}\n ${row.country}`),
154
+ ].join('\n');
155
+ }
156
+ if (data.view === 'devices') {
157
+ if (data.rows.length === 0)
158
+ return 'No Search Console device rows match these filters.';
159
+ return [
160
+ `Search Console devices (${data.rows.length} of ${data.total})`,
161
+ ...data.rows.map(row => `${row.clicks} clicks ${row.impressions} impressions position ${row.pos.toFixed(1)}\n ${row.device}`),
162
+ ].join('\n');
163
+ }
164
+ if (data.view === 'timeseries') {
165
+ if (data.daily.length === 0)
166
+ return 'No Search Console days match this period.';
167
+ return [
168
+ `Search Console daily totals (${data.daily.length} days, ${data.period})`,
169
+ ...data.daily.map(row => `${row.date} ${row.clicks} clicks ${row.impressions} impressions position ${row.pos.toFixed(1)}`),
170
+ ].join('\n');
171
+ }
172
+ if (data.view === 'page-detail') {
173
+ if (data.rows.length === 0)
174
+ return `No Search Console queries match ${data.pageUrl}.`;
175
+ return [
176
+ `Search Console queries for ${data.pageUrl} (${data.rows.length})`,
177
+ ...data.rows.map(row => `${row.clicks} clicks ${row.impressions} impressions position ${row.pos.toFixed(1)}\n ${row.keyword}`),
178
+ ].join('\n');
179
+ }
180
+ if (data.view === 'keyword-detail') {
181
+ if (data.rows.length === 0)
182
+ return `No Search Console Pages match ${data.keyword}.`;
183
+ return [
184
+ `Search Console Pages for ${data.keyword} (${data.rows.length})`,
185
+ ...data.rows.map(row => `${row.clicks} clicks ${row.impressions} impressions position ${row.pos.toFixed(1)}\n ${row.page}`),
186
+ ].join('\n');
187
+ }
188
+ if (data.rows.length === 0)
189
+ return `No rows match the ${data.preset} analysis.`;
190
+ return [
191
+ `${data.preset} (${data.rows.length} of ${data.total}) ${data.description}`,
192
+ ...data.rows.map(row => [
193
+ `${row.clicks} clicks ${row.impressions} impressions position ${row.pos.toFixed(1)}`,
194
+ ` ${row.keyword}`,
195
+ row.page ? ` ${row.page}` : '',
196
+ ].filter(Boolean).join('\n')),
197
+ ].join('\n');
198
+ }
199
+ export function renderIndexingHistory(data) {
200
+ const scope = data.scope._tag === 'url' ? data.scope.url : `site, ${data.scope.field}`;
201
+ const header = fields([
202
+ ['Scope', scope],
203
+ ['Window', `${data.startDate} to ${data.endDate}`],
204
+ ['Complete', data.complete],
205
+ ['Resolved URL', data.resolvedUrl],
206
+ ]);
207
+ if (data.changes.length === 0) {
208
+ const observed = data.unchanged
209
+ ? `No indexing changes. ${data.unchanged.observations ?? 'unknown'} observations from ${data.unchanged.firstObservedDay ?? 'unknown'} to ${data.unchanged.lastObservedDay ?? 'unknown'}.`
210
+ : 'No indexing changes were retained for this window.';
211
+ return `${header}\n${observed}`;
212
+ }
213
+ return [
214
+ header,
215
+ `Changes (${data.changes.length})`,
216
+ ...data.changes.map((change) => {
217
+ const cause = change.cause._tag === 'sole-candidate'
218
+ ? `${change.cause.marker.day} ${change.cause.marker.label}`
219
+ : change.cause._tag === 'ambiguous'
220
+ ? `${change.cause.markers.length} candidate changes`
221
+ : 'no known change';
222
+ return [
223
+ `${change.field}: ${change.fromValue ?? 'none'} to ${change.toValue ?? 'none'}`,
224
+ ` ${change.url}`,
225
+ ` observed between ${change.afterDay} and ${change.beforeDay} (${change.observedOverDays} days)`,
226
+ ` cause: ${cause}`,
227
+ ].join('\n');
228
+ }),
229
+ ].join('\n');
230
+ }
231
+ export function renderUrlInspection(data) {
232
+ if (data._tag === 'no-inspection-data')
233
+ return `No retained URL Inspection for ${data.requestedUrl}.`;
234
+ if (data._tag === 'retained-evidence') {
235
+ return [
236
+ `Retained evidence for ${data.requestedUrl}. Google has not inspected this URL.`,
237
+ fields([
238
+ ['Evidence URL', data.evidenceUrl],
239
+ ['Issue', data.issueType],
240
+ ['Label', data.label],
241
+ ['Coverage', data.coverageState],
242
+ ['User canonical', data.userCanonical],
243
+ ['Google canonical', data.googleCanonical],
244
+ ['Canonicalised away', data.canonicalisedAway],
245
+ ['Observed', data.observedAt],
246
+ ['Last seen', data.lastSeenAt],
247
+ ]),
248
+ ].join('\n');
249
+ }
250
+ return [
251
+ fields([
252
+ ['Requested URL', data.requestedUrl],
253
+ ['Inspected URL', data.inspectedUrl],
254
+ ['Verdict', data.verdict],
255
+ ['Coverage', data.coverageState],
256
+ ['Indexing', data.indexingState],
257
+ ['robots.txt', data.robotsTxtState],
258
+ ['Page fetch', data.pageFetchState],
259
+ ['Crawled as', data.crawledAs],
260
+ ['Last crawl', data.lastCrawlTime],
261
+ ['User canonical', data.userCanonical],
262
+ ['Google canonical', data.googleCanonical],
263
+ ['Canonicalised away', data.canonicalisedAway],
264
+ ['Canonical mismatch', data.canonicalMismatchKind],
265
+ ['Last checked', data.lastCheckedAt],
266
+ ]),
267
+ `Sitemaps: ${data.sitemaps.length === 0 ? 'none' : data.sitemaps.join(', ')}`,
268
+ `Referring URLs: ${data.referringUrls.length}`,
269
+ ].join('\n');
270
+ }
271
+ export function renderAuditChanges(data) {
272
+ if (data.state === 'no-crawl')
273
+ return 'No crawl has finished for this Site.';
274
+ if (data.state === 'no-baseline')
275
+ return 'Only one crawl exists. Run another crawl to compare.';
276
+ const header = fields([
277
+ ['State', data.state],
278
+ ['Verdict', data.verdict],
279
+ ['Observed', data.window.observedAt],
280
+ ['Compared to', data.window.comparedTo],
281
+ ['Sampled', data.sampled],
282
+ ['Clicks at risk', data.clicksAtRisk],
283
+ ]);
284
+ const facet = (label, groups) => {
285
+ if (groups.groups.length === 0)
286
+ return [`${label}: none`];
287
+ return [
288
+ `${label} (${groups.groupTotal} groups, ${groups.pages} Pages)`,
289
+ ...groups.groups.map(group => [
290
+ `${group.pages} Pages ${group.severity} ${group.label}${group.spike ? ' spike' : ''}`,
291
+ ...group.examples.slice(0, 3).map(example => ` ${example.url}`),
292
+ ].join('\n')),
293
+ ];
294
+ };
295
+ return [
296
+ header,
297
+ ...facet('Regressions', data.regressions),
298
+ ...facet('Resolved', data.resolved),
299
+ ].join('\n');
300
+ }
301
+ export function renderLinkStructure(data) {
302
+ const header = fields([
303
+ ['Search Console', data.gscConnected],
304
+ ['Internal links', data.internalLinks],
305
+ ['Template links', `${data.templateLinks} to ${data.templateTargets} targets`],
306
+ ['Editorial links', `${data.editorialLinks} to ${data.editorialTargets} targets`],
307
+ ]);
308
+ const slots = data.templateSlots.length === 0
309
+ ? ['Template slots: none']
310
+ : [
311
+ `Template slots (${data.templateSlots.length} of ${data.templateSlotTotal})`,
312
+ ...data.templateSlots.map(slot => `${slot.templateLinks} links from ${slot.sourcePages} Pages ${(slot.share * 100).toFixed(1)}%\n ${slot.url}`),
313
+ ];
314
+ const deadEnds = data.deadEnds.length === 0
315
+ ? ['Dead ends: none']
316
+ : [
317
+ `Dead ends (${data.deadEnds.length} of ${data.deadEndGroupTotal} groups, ${data.deadEndPageTotal} Pages)${data.deadEndScanTruncated ? ' truncated' : ''}`,
318
+ ...data.deadEnds.map(group => `${group.pages} Pages ${group.label}`),
319
+ ];
320
+ const anchors = data.genericAnchors.length === 0
321
+ ? ['Generic anchors: none']
322
+ : [
323
+ `Generic anchors (${data.genericAnchors.length} of ${data.genericAnchorTotal}, ${data.genericAnchorLinks} links)`,
324
+ ...data.genericAnchors.map(anchor => `${anchor.links} links to ${anchor.targets} targets ${anchor.anchor}`),
325
+ ];
326
+ return [header, ...slots, ...deadEnds, ...anchors].join('\n');
327
+ }
328
+ export function renderSitemaps(data) {
329
+ if (data.sitemaps.length === 0)
330
+ return `No sitemaps are known for ${data.meta.siteUrl}.`;
331
+ return [
332
+ `Sitemaps for ${data.meta.siteUrl} (${data.sitemaps.length}), sync ${data.meta.syncStatus}`,
333
+ ...data.sitemaps.map(sitemap => [
334
+ `${sitemap.urlCount ?? 'unknown'} URLs ${sitemap.errors} errors ${sitemap.warnings} warnings${sitemap.isIndex ? ' index' : ''}${sitemap.isPending ? ' pending' : ''}`,
335
+ ` ${sitemap.path}`,
336
+ ` last downloaded ${sitemap.lastDownloaded ?? 'never'}`,
337
+ ].join('\n')),
338
+ ].join('\n');
339
+ }
340
+ export function renderSitemapUrls(data) {
341
+ if (data.items.length === 0)
342
+ return 'No sitemap URLs match these filters.';
343
+ return [
344
+ `Sitemap URLs (${data.items.length}), generation ${data.generation.id}`,
345
+ ...data.items.map(item => `${item.feedpath}\n ${item.url}`),
346
+ data.page.nextCursor ? `More URLs available after cursor ${data.page.nextCursor}.` : '',
347
+ ].filter(Boolean).join('\n');
348
+ }
349
+ export function renderSitemapAction(data) {
148
350
  return fields([
149
- ['View', data.type],
150
- ['Period', data.period],
151
- ['Rows', 'rows' in data ? data.rows.length : data.type === 'timeseries' ? data.daily.length : 0],
351
+ ['Action', data.action],
352
+ ['Sitemap', data.sitemapUrl],
353
+ ['Sitemaps now', data.sitemapCount],
152
354
  ]);
153
355
  }
356
+ /**
357
+ * Web Analytics rows differ per view and share no discriminant field, so the
358
+ * requested view selects the projection instead of the payload.
359
+ */
360
+ export function renderAnalytics(view, data) {
361
+ const delta = (label, value) => [
362
+ label,
363
+ `${value.current} (was ${value.prior}, ${(value.relative * 100).toFixed(1)}%)`,
364
+ ];
365
+ const record = data;
366
+ if (view === 'performance') {
367
+ return fields([
368
+ ['Provider', record.provider],
369
+ ['Phase', record.phase],
370
+ delta('Sessions', record.sessions),
371
+ delta('Users', record.users),
372
+ delta('Pageviews', record.pageviews),
373
+ delta('Engagement rate', record.engagementRate),
374
+ delta('Bounce rate', record.bounceRate),
375
+ delta('Key events', record.keyEvents),
376
+ ['Days', record.daily.length],
377
+ ['Data from', record.dataFrom],
378
+ ]);
379
+ }
380
+ if (view === 'key-events') {
381
+ return fields([
382
+ ['Provider', record.provider],
383
+ delta('Key events', record.total),
384
+ ['Days', record.daily?.length ?? 0],
385
+ ]);
386
+ }
387
+ const rows = (record.rows ?? []);
388
+ if (rows.length === 0)
389
+ return `No Web Analytics rows for ${view}.`;
390
+ const label = (row) => {
391
+ for (const key of ['page', 'source', 'country', 'device', 'label']) {
392
+ if (typeof row[key] === 'string')
393
+ return row[key];
394
+ }
395
+ return 'unknown';
396
+ };
397
+ return [
398
+ `Web Analytics ${view} (${rows.length}), provider ${record.provider}${view === 'dimension' ? `, dimension ${record.dimension}` : ''}`,
399
+ ...rows.map(row => `${row.sessions ?? 0} sessions ${row.users ?? 0} users ${row.pageviews ?? 0} pageviews\n ${label(row)}`),
400
+ ].join('\n');
401
+ }
402
+ export function renderAccountToken(data) {
403
+ return [
404
+ fields([
405
+ ['Credential', data.credential],
406
+ ['Team', `${data.team.name ?? 'unnamed'} (${data.team.id})`],
407
+ ['Plan', data.team.plan],
408
+ ['Role', data.role],
409
+ ['User', data.user.email ?? data.user.id],
410
+ ['Token', data.token ? `${data.token.label ?? 'unlabelled'} (${data.token.id})` : 'none, browser session'],
411
+ ['Expires', data.token?.expiresAt ?? 'never'],
412
+ ['Last used', data.token?.lastUsedAt ?? 'never'],
413
+ ]),
414
+ `Scopes: ${data.scopes.join(', ')}`,
415
+ ].join('\n');
416
+ }
154
417
  export function renderIndexingDiagnostics(data) {
155
418
  if (data.view === 'summary') {
156
419
  const indexedPercent = data.totalUrls > 0
@@ -270,6 +533,18 @@ export function renderLinkOpportunities(data) {
270
533
  ...data.rows.map(row => `${row.phrase}\n From: ${row.sourceUrl}\n To: ${row.targetUrl}`),
271
534
  ].join('\n');
272
535
  }
536
+ export function renderTimeline(data) {
537
+ if (data.entries.length === 0)
538
+ return 'No Timeline Entries match these filters.';
539
+ return [
540
+ `Timeline (${data.entries.length})`,
541
+ ...data.entries.map((entry) => {
542
+ const label = typeof entry.rendered?.label === 'string' ? entry.rendered.label : entry.kind;
543
+ return `${entry.occurredAt} ${entry.severity ?? 'unrated'} ${entry.kind}\n ${label}`;
544
+ }),
545
+ data.nextCursor ? `More Timeline Entries available — pass --cursor ${JSON.stringify(data.nextCursor)}.` : '',
546
+ ].filter(Boolean).join('\n');
547
+ }
273
548
  export function renderRecoverableBacklinks(data) {
274
549
  if (data.items.length === 0)
275
550
  return 'No recoverable Backlinks. Every stored inbound link resolves.';
@@ -307,3 +582,371 @@ export function renderMentions(data) {
307
582
  ].join('\n')),
308
583
  ].join('\n');
309
584
  }
585
+ const VITAL_UNIT = { lcp: 'ms', inp: 'ms', cls: '' };
586
+ function vitalValue(metric, p75) {
587
+ if (p75 === null)
588
+ return 'none';
589
+ return `${p75}${VITAL_UNIT[metric]}`;
590
+ }
591
+ export function renderFieldVitals(data) {
592
+ const header = fields([
593
+ ['Target', data.target],
594
+ ['Form factor', data.formFactor],
595
+ ]);
596
+ if (!data.available)
597
+ return `${header}\n${data.message ?? 'No CrUX data for this target.'}`;
598
+ if (data.trend) {
599
+ const lines = ['lcp', 'inp', 'cls'].flatMap((metric) => {
600
+ const points = data.trend[metric];
601
+ if (points.length === 0)
602
+ return [`${metric.toUpperCase()} no samples`];
603
+ return [
604
+ `${metric.toUpperCase()} (${points.length} weeks)`,
605
+ ...points.map(point => ` ${point.date} ${vitalValue(metric, point.p75)}`),
606
+ ];
607
+ });
608
+ return [header, ...lines].join('\n');
609
+ }
610
+ const summary = data.summary;
611
+ if (!summary)
612
+ return header;
613
+ return [
614
+ header,
615
+ fields([
616
+ ['Overall', summary.overall],
617
+ ['As of', summary.asOf],
618
+ ...['lcp', 'inp', 'cls'].map(metric => [
619
+ metric.toUpperCase(),
620
+ `${vitalValue(metric, summary[metric].p75)} ${summary[metric].status ?? 'unrated'} delta ${summary[metric].deltaOverPeriod ?? 'none'}`,
621
+ ]),
622
+ ]),
623
+ ].join('\n');
624
+ }
625
+ export function renderFieldVitalFindings(data) {
626
+ if (data.findings.length === 0)
627
+ return data.message ?? 'No failing field vitals attributed to an element.';
628
+ const shown = `${data.offset + 1}-${data.offset + data.findings.length} of ${data.total}`;
629
+ return [
630
+ `Field vital findings (${shown}, source ${data.source ?? 'unknown'})`,
631
+ ...data.findings.map(finding => [
632
+ `${finding.metric.toUpperCase()} ${finding.severity} ${vitalValue(finding.metric, finding.p75)} ${finding.route}`,
633
+ ` selector ${finding.selector}`,
634
+ ` est. views ${finding.estimatedViews}`,
635
+ ` ${finding.fixPrompt.split('\n').join('\n ')}`,
636
+ ].join('\n')),
637
+ ].join('\n');
638
+ }
639
+ function spendLine(evidence) {
640
+ return ['Source', `${evidence._tag} (${evidence.spend.units} unit)`];
641
+ }
642
+ export function renderBacklinksSummary(data) {
643
+ return fields([
644
+ ['Target', data.target],
645
+ ['Domain rank', data.rank],
646
+ ['Backlinks', data.backlinks],
647
+ ['Referring domains', data.referringDomains],
648
+ ['Referring main domains', data.referringMainDomains],
649
+ ['Referring pages', data.referringPages],
650
+ ['Broken backlinks', data.brokenBacklinks],
651
+ ['Broken pages', data.brokenPages],
652
+ ['Spam score', data.spamScore],
653
+ spendLine(data.evidence),
654
+ ]);
655
+ }
656
+ export function renderReferringDomains(data) {
657
+ if (data.items.length === 0)
658
+ return `No referring domains for ${data.target}.`;
659
+ return [
660
+ `Referring domains for ${data.target} (${data.items.length} of ${data.total})`,
661
+ ...data.items.map(item => [
662
+ `${item.domain}`,
663
+ ` ${fields([
664
+ ['rank', item.rank],
665
+ ['backlinks', item.backlinks],
666
+ ['referring pages', item.referringPages],
667
+ ['broken backlinks', item.brokenBacklinks],
668
+ ['spam score', item.spamScore],
669
+ ['sessions (90d)', item.sessions],
670
+ ['first seen', item.firstSeen],
671
+ ]).split('\n').join('\n ')}`,
672
+ ].join('\n')),
673
+ fields([spendLine(data.evidence)]),
674
+ ].join('\n');
675
+ }
676
+ export function renderBacklinkAnchors(data) {
677
+ if (data.items.length === 0)
678
+ return `No anchors for ${data.target}.`;
679
+ return [
680
+ `Backlink anchors for ${data.target} (${data.items.length} of ${data.total})`,
681
+ ...data.items.map(item => [
682
+ `${item.anchor || '(empty anchor)'}`,
683
+ ` ${fields([
684
+ ['backlinks', item.backlinks],
685
+ ['referring domains', item.referringDomains],
686
+ ['referring pages', item.referringPages],
687
+ ['spam score', item.spamScore],
688
+ ['first seen', item.firstSeen],
689
+ ]).split('\n').join('\n ')}`,
690
+ ].join('\n')),
691
+ fields([spendLine(data.evidence)]),
692
+ ].join('\n');
693
+ }
694
+ export function renderBacklinksHistory(data) {
695
+ if (data.items.length === 0)
696
+ return `No Backlinks history for ${data.target}.`;
697
+ return [
698
+ `Backlinks history for ${data.target} (${data.items.length} months)`,
699
+ ...data.items.map(item => `${item.date} backlinks ${item.backlinks ?? 'none'} domains ${item.referringDomains ?? 'none'} gained ${item.newReferringDomains ?? 'none'} lost ${item.lostReferringDomains ?? 'none'}`),
700
+ fields([spendLine(data.evidence)]),
701
+ ].join('\n');
702
+ }
703
+ export function renderSiteStatus(data) {
704
+ if (!data.available)
705
+ return data.message ?? `No assessment for ${data.site} yet.`;
706
+ const blocks = [];
707
+ if (data.assessment) {
708
+ blocks.push([
709
+ data.assessment.summary,
710
+ ` ${data.assessment.recommendation}`,
711
+ ].join('\n'));
712
+ }
713
+ blocks.push(fields([
714
+ ['Site', data.site],
715
+ ['Reach', data.assessment?.reachStage],
716
+ ['Health', data.assessment?.healthStage],
717
+ ['Assessed', data.assessment?.assessedAt],
718
+ ['Data quality', data.dataQuality.status === 'complete'
719
+ ? 'complete'
720
+ : `${data.dataQuality.status}${data.dataQuality.reason ? ` (${data.dataQuality.reason})` : ''}`],
721
+ ['Open', data.openCount],
722
+ ['Verified', data.verifiedCount],
723
+ ]));
724
+ if (data.dataQuality.unavailableProofs.length)
725
+ blocks.push(`Unavailable proofs: ${data.dataQuality.unavailableProofs.join(', ')}`);
726
+ if (data.degradedSteps.length)
727
+ blocks.push(`Degraded pipeline steps: ${data.degradedSteps.join(', ')}`);
728
+ if (data.nextAction) {
729
+ blocks.push([
730
+ `Next action ${data.nextAction.label}${data.nextAction.urgent ? ' (urgent)' : ''}`,
731
+ ` ${fields([
732
+ ['detail', data.nextAction.detail],
733
+ ['dimension', data.nextAction.dimension],
734
+ ['impact', data.nextAction.impact],
735
+ ['action id', data.nextAction.actionId],
736
+ ]).split('\n').join('\n ')}`,
737
+ ].join('\n'));
738
+ }
739
+ if (data.changes.length) {
740
+ blocks.push([
741
+ `What changed (${data.changes.length})`,
742
+ ...data.changes.map(change => ` ${DIRECTION_MARK[change.direction]} ${change.label}${change.value ? ` ${change.value}` : ''}${change.window ? ` ${change.window}` : ''}`),
743
+ ].join('\n'));
744
+ }
745
+ if (data.receipts.length) {
746
+ blocks.push([
747
+ `Receipts (${data.receipts.length})`,
748
+ ...data.receipts.map(receipt => ` ${receipt.observedAt} ${receipt.title}`),
749
+ ].join('\n'));
750
+ }
751
+ return blocks.join('\n\n');
752
+ }
753
+ const DIRECTION_MARK = { up: 'up', down: 'down', flat: 'flat' };
754
+ export function renderActionDismissal(data) {
755
+ return fields([
756
+ ['Action', data.actionId],
757
+ ['Status', data.status],
758
+ ['Result', data.message],
759
+ ]);
760
+ }
761
+ export function renderPageIssues(data) {
762
+ if (data.issues.length === 0) {
763
+ const keys = data.availableKeys?.length
764
+ ? `\nAvailable source and issue pairs:\n${data.availableKeys.map(key => ` ${key.source} ${key.issue} ${key.openCount} open`).join('\n')}`
765
+ : '';
766
+ return `${data.message ?? 'No open Page Issues matched.'}${keys}`;
767
+ }
768
+ const shown = `${data.offset + 1}-${data.offset + data.issues.length} of ${data.total}`;
769
+ const truncated = data.coverage.possiblyTruncated
770
+ ? `\nStorage caps this source at ${data.coverage.perSourceCap} rows, so the total is a floor.`
771
+ : '';
772
+ return [
773
+ `Page Issues (${shown})`,
774
+ ...data.issues.map(issue => [
775
+ `${issue.severity} ${issue.source}/${issue.issueType} ${issue.url}`,
776
+ ` ${fields([
777
+ ['label', issue.detail.label ?? issue.detail.title],
778
+ ['status', issue.detail.statusCode],
779
+ ['redirects to', issue.detail.redirectTarget],
780
+ ['observed', issue.detail.observedAt ?? issue.lastSeenAt],
781
+ ['linked from', issue.linkedFromTotal],
782
+ ['resolution', issue.resolution],
783
+ ]).split('\n').join('\n ')}`,
784
+ ...issue.detail.elements.slice(0, 3).map(element => ` ${element.selector ?? element.message ?? ''}`.trimEnd()),
785
+ ].filter(Boolean).join('\n')),
786
+ ].join('\n') + truncated;
787
+ }
788
+ export function renderScans(data) {
789
+ if (data.scans.length === 0)
790
+ return 'No Lighthouse Scans retained for this Site.';
791
+ return [
792
+ `Scans (${data.scans.length})`,
793
+ ...data.scans.map(scan => [
794
+ `${scan.completedAt ?? 'pending'} ${scan.strategy} ${scan.status} ${scan.url}`,
795
+ ` ${fields([
796
+ ['id', scan.id],
797
+ ['performance', scan.scores.perf],
798
+ ['accessibility', scan.scores.a11y],
799
+ ['best practices', scan.scores.bestPractices],
800
+ ['seo', scan.scores.seo],
801
+ ['lcp', scan.lcp],
802
+ ['tbt', scan.tbt],
803
+ ['cls', scan.cls],
804
+ ['error', scan.error],
805
+ ]).split('\n').join('\n ')}`,
806
+ ].join('\n')),
807
+ ].join('\n');
808
+ }
809
+ export function renderMonitoredPages(data) {
810
+ if (data.pages.length === 0)
811
+ return 'No monitored Pages. Start one with `nuxtseo page scan <url>`.';
812
+ return [
813
+ `Monitored Pages (${data.pages.length})`,
814
+ ...data.pages.map(page => ` ${page.enabled ? 'on ' : 'off'} ${page.path} last scan ${page.lastScanAt ?? 'never'}`),
815
+ ].join('\n');
816
+ }
817
+ export function renderScanDetail(data) {
818
+ const blocks = [
819
+ fields([
820
+ ['Scan', data.id],
821
+ ['URL', data.url],
822
+ ['Strategy', data.strategy],
823
+ ['Status', data.status],
824
+ ['Completed', data.completedAt],
825
+ ['Performance', data.scores.perf],
826
+ ['Accessibility', data.scores.a11y],
827
+ ['Best practices', data.scores.bestPractices],
828
+ ['SEO', data.scores.seo],
829
+ ['LCP', data.lcp],
830
+ ['TBT', data.tbt],
831
+ ['CLS', data.cls],
832
+ ['FCP', data.fcp],
833
+ ['Speed Index', data.speedIndex],
834
+ ['TTFB', data.ttfb],
835
+ ]),
836
+ ];
837
+ if (data.lcpElement)
838
+ blocks.push(`LCP element ${data.lcpElement.selector ?? data.lcpElement.nodeLabel ?? 'unknown'}`);
839
+ if (data.performanceIssues.length) {
840
+ blocks.push([
841
+ `Wasteful assets (${data.performanceIssues.length})`,
842
+ ...data.performanceIssues.map(issue => ` ${issue.wastedMs ?? 0}ms ${issue.wastedBytes ?? 0}b ${issue.subtype ?? issue.type} ${issue.url ?? ''}`.trimEnd()),
843
+ ].join('\n'));
844
+ }
845
+ if (data.thirdParties.length) {
846
+ blocks.push([
847
+ `Third parties (${data.thirdParties.length})`,
848
+ ...data.thirdParties.map(script => ` ${script.blockingTime ?? 0}ms blocking ${script.entity ?? 'unknown'}`),
849
+ ].join('\n'));
850
+ }
851
+ for (const [label, checks] of [['Accessibility failures', data.accessibility], ['Best-practices failures', data.bestPractices]]) {
852
+ if (!checks.length)
853
+ continue;
854
+ blocks.push([
855
+ `${label} (${checks.length})`,
856
+ ...checks.map(check => [
857
+ ` ${check.severity} ${check.title} ${check.elementsTotal ?? check.instanceCount} element(s)`,
858
+ ...check.elements.slice(0, 3).map(element => ` ${element.selector ?? element.message ?? ''}`.trimEnd()),
859
+ ].join('\n')),
860
+ ].join('\n'));
861
+ }
862
+ if (data.coverage.possiblyTruncated)
863
+ blocks.push(`Capped at ${data.coverage.perScanCheckCap} checks and ${data.coverage.perCheckElementCap} elements per check; totals are ${data.coverage.accessibilityChecksTotal} accessibility and ${data.coverage.bestPracticesChecksTotal} best-practices checks.`);
864
+ return blocks.join('\n\n');
865
+ }
866
+ export function renderAnnotations(data) {
867
+ if (data.annotations.length === 0)
868
+ return 'No annotations on this Site.';
869
+ return [
870
+ `Annotations (${data.annotations.length} of ${data.limit} max)`,
871
+ ...data.annotations.map(annotation => ` ${annotation.date} ${annotation.title}${annotation.url ? ` ${annotation.url}` : ''} [${annotation.id}]`),
872
+ ].join('\n');
873
+ }
874
+ export function renderAnnotation(data) {
875
+ return fields([
876
+ ['Annotation', data.annotation.id],
877
+ ['Date', data.annotation.date],
878
+ ['Title', data.annotation.title],
879
+ ['Note', data.annotation.note],
880
+ ['Link', data.annotation.url],
881
+ ]);
882
+ }
883
+ export function renderAnnotationDeleted(data) {
884
+ return `Deleted annotation ${data.annotationId}.`;
885
+ }
886
+ export function renderIndexCohorts(data) {
887
+ if (!data.analysed)
888
+ return data.message ?? 'Route-family indexing analysis is unavailable.';
889
+ const blocks = [
890
+ fields([
891
+ ['As of', data.asOf],
892
+ ['Pages analysed', data.baseline?.pages],
893
+ ['Not indexed', data.baseline?.notIndexed],
894
+ ['Not indexed %', data.baseline?.notIndexedPercent],
895
+ ['Families tested', data.familiesTested],
896
+ ]),
897
+ ];
898
+ if (data.established.length) {
899
+ blocks.push([
900
+ `Established worse than the rest of the Site (${data.established.length})`,
901
+ ...data.established.map(cell => ` ${cell.family} ${cell.notIndexedPercent}% of ${cell.pages} pages rest of Site ${cell.restOfSitePercent}% CI ${cell.confidenceIntervalLower}-${cell.confidenceIntervalUpper}%`),
902
+ ].join('\n'));
903
+ }
904
+ else if (data.establishedNote) {
905
+ blocks.push(data.establishedNote);
906
+ }
907
+ if (data.ranked.length) {
908
+ blocks.push([
909
+ `Ranked by raw rate, not tested (${data.ranked.length} of ${data.rankedTotal})`,
910
+ 'A row marked "lead" has NOT been shown to differ from the rest of the Site. Report it as a lead to check, never as a cause.',
911
+ ...data.ranked.map(section => ` ${section.notIndexedPercent}% ${section.notIndexed}/${section.pages} ${section.family} ${section.statisticallyEstablished ? 'established' : 'lead'}`),
912
+ ].join('\n'));
913
+ }
914
+ return blocks.join('\n\n');
915
+ }
916
+ export function renderDomainTraffic(data) {
917
+ const blocks = [
918
+ fields([
919
+ ['Domain', data.domain],
920
+ ['Monthly traffic', data.monthlyTraffic],
921
+ ['Trend', data.trend],
922
+ ['Change %', data.changePercent],
923
+ ['Traffic value', data.trafficValue],
924
+ ['Keyword footprint', data.keywordFootprint],
925
+ ['Source', `${data.evidence._tag} (${data.evidence.spend.units} unit)`],
926
+ ]),
927
+ ];
928
+ if (data.topPages.length) {
929
+ blocks.push([
930
+ `Top pages (${data.topPages.length})`,
931
+ ...data.topPages.map(page => ` ${page.traffic} ${page.keywords} keywords ${page.path}`),
932
+ ].join('\n'));
933
+ }
934
+ if (data.topCountries.length) {
935
+ blocks.push([
936
+ `Top countries (${data.topCountries.length})`,
937
+ ...data.topCountries.map(row => ` ${row.percent}% ${row.country}`),
938
+ ].join('\n'));
939
+ }
940
+ if (data.message)
941
+ blocks.push(data.message);
942
+ return blocks.join('\n\n');
943
+ }
944
+ export function renderDomainAvailability(data) {
945
+ if (data.results.length === 0)
946
+ return data.message ?? 'No domains checked.';
947
+ return [
948
+ `Domains (${data.results.length})`,
949
+ ...data.results.map(row => ` ${row.available ? 'available' : 'taken '} ${row.domain}${row.registrar ? ` ${row.registrar}` : ''}${row.expiration ? ` expires ${row.expiration}` : ''}`),
950
+ fields([['Source', `${data.evidence._tag} (${data.evidence.spend.units} unit)`]]),
951
+ ].join('\n');
952
+ }