@cloudhal/mcp-server 0.0.0-stage → 0.1.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 (105) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +18 -2
  3. package/dist/src/analytics-reports.d.ts +164 -0
  4. package/dist/src/analytics-reports.js +632 -0
  5. package/dist/src/analytics-tools.d.ts +4 -0
  6. package/dist/src/analytics-tools.js +597 -0
  7. package/dist/src/asset-tools.d.ts +5 -0
  8. package/dist/src/asset-tools.js +685 -0
  9. package/dist/src/body-tools.d.ts +46 -0
  10. package/dist/src/body-tools.js +708 -0
  11. package/dist/src/changes.d.ts +10 -0
  12. package/dist/src/changes.js +112 -0
  13. package/dist/src/chrome-tools.d.ts +8 -0
  14. package/dist/src/chrome-tools.js +207 -0
  15. package/dist/src/comment-tools.d.ts +9 -0
  16. package/dist/src/comment-tools.js +743 -0
  17. package/dist/src/comments.d.ts +184 -0
  18. package/dist/src/comments.js +408 -0
  19. package/dist/src/common.d.ts +258 -0
  20. package/dist/src/common.js +488 -0
  21. package/dist/src/content-shared.d.ts +50 -0
  22. package/dist/src/content-shared.js +102 -0
  23. package/dist/src/design-tools.d.ts +9 -0
  24. package/dist/src/design-tools.js +349 -0
  25. package/dist/src/elevation-tools.d.ts +3 -0
  26. package/dist/src/elevation-tools.js +331 -0
  27. package/dist/src/entry-terms.d.ts +14 -0
  28. package/dist/src/entry-terms.js +51 -0
  29. package/dist/src/entry-tools.d.ts +4 -0
  30. package/dist/src/entry-tools.js +442 -0
  31. package/dist/src/http.d.ts +48 -0
  32. package/dist/src/http.js +101 -0
  33. package/dist/src/index.d.ts +10 -0
  34. package/dist/src/index.js +10 -0
  35. package/dist/src/menu-tools.d.ts +41 -0
  36. package/dist/src/menu-tools.js +1058 -0
  37. package/dist/src/page-tools.d.ts +5 -0
  38. package/dist/src/page-tools.js +311 -0
  39. package/dist/src/preview-doc.d.ts +9 -0
  40. package/dist/src/preview-doc.js +56 -0
  41. package/dist/src/preview-view.d.ts +19 -0
  42. package/dist/src/preview-view.js +71 -0
  43. package/dist/src/publish-tools.d.ts +10 -0
  44. package/dist/src/publish-tools.js +248 -0
  45. package/dist/src/render/cdp.d.ts +29 -0
  46. package/dist/src/render/cdp.js +190 -0
  47. package/dist/src/render/checks/components.d.ts +5 -0
  48. package/dist/src/render/checks/components.js +95 -0
  49. package/dist/src/render/checks/content.d.ts +5 -0
  50. package/dist/src/render/checks/content.js +63 -0
  51. package/dist/src/render/checks/contrast.d.ts +5 -0
  52. package/dist/src/render/checks/contrast.js +72 -0
  53. package/dist/src/render/checks/index.d.ts +1 -0
  54. package/dist/src/render/checks/index.js +29 -0
  55. package/dist/src/render/checks/layout.d.ts +5 -0
  56. package/dist/src/render/checks/layout.js +44 -0
  57. package/dist/src/render/checks/motion.d.ts +5 -0
  58. package/dist/src/render/checks/motion.js +66 -0
  59. package/dist/src/render/checks/navigation.d.ts +5 -0
  60. package/dist/src/render/checks/navigation.js +78 -0
  61. package/dist/src/render/checks/prelude.d.ts +1 -0
  62. package/dist/src/render/checks/prelude.js +64 -0
  63. package/dist/src/render/limiter.d.ts +11 -0
  64. package/dist/src/render/limiter.js +29 -0
  65. package/dist/src/render/page.d.ts +19 -0
  66. package/dist/src/render/page.js +135 -0
  67. package/dist/src/render/types.d.ts +89 -0
  68. package/dist/src/render/types.js +140 -0
  69. package/dist/src/render-tools.d.ts +14 -0
  70. package/dist/src/render-tools.js +314 -0
  71. package/dist/src/render.d.ts +14 -0
  72. package/dist/src/render.js +33 -0
  73. package/dist/src/schedule-tools.d.ts +2 -0
  74. package/dist/src/schedule-tools.js +1596 -0
  75. package/dist/src/schedules.d.ts +292 -0
  76. package/dist/src/schedules.js +890 -0
  77. package/dist/src/schema-transforms.d.ts +14 -0
  78. package/dist/src/schema-transforms.js +50 -0
  79. package/dist/src/search-tools.d.ts +2 -0
  80. package/dist/src/search-tools.js +112 -0
  81. package/dist/src/server-approvals.d.ts +51 -0
  82. package/dist/src/server-approvals.js +273 -0
  83. package/dist/src/server-call.d.ts +28 -0
  84. package/dist/src/server-call.js +259 -0
  85. package/dist/src/server-instructions.d.ts +4 -0
  86. package/dist/src/server-instructions.js +74 -0
  87. package/dist/src/server-resources.d.ts +4 -0
  88. package/dist/src/server-resources.js +83 -0
  89. package/dist/src/server-schema.d.ts +20 -0
  90. package/dist/src/server-schema.js +82 -0
  91. package/dist/src/server.d.ts +80 -0
  92. package/dist/src/server.js +135 -0
  93. package/dist/src/stdio.d.ts +9 -0
  94. package/dist/src/stdio.js +18 -0
  95. package/dist/src/surface-tools.d.ts +4 -0
  96. package/dist/src/surface-tools.js +357 -0
  97. package/dist/src/taxonomy-tools.d.ts +16 -0
  98. package/dist/src/taxonomy-tools.js +1433 -0
  99. package/dist/src/terms.d.ts +47 -0
  100. package/dist/src/terms.js +271 -0
  101. package/dist/src/tools.d.ts +39 -0
  102. package/dist/src/tools.js +511 -0
  103. package/dist/src/translation-tools.d.ts +3 -0
  104. package/dist/src/translation-tools.js +422 -0
  105. package/package.json +86 -3
@@ -0,0 +1,632 @@
1
+ /**
2
+ * Analytics reports shared by the REST routes (`packages/api/src/routes/analytics.ts`) and the MCP
3
+ * tools (`analytics-tools.ts`), RFC-0006 §9: queries of the model through `AnalyticsService`, joined
4
+ * to the **current** graph by node id (a renamed article shows its new title and path; a deleted
5
+ * one reads as gone). Every string returned is graph data, an enum, a hostname, a UTM token or —
6
+ * for render reports only — a percent-encoded requested path (§5.5, invariant A9).
7
+ */
8
+
9
+
10
+
11
+
12
+
13
+
14
+
15
+
16
+
17
+
18
+
19
+
20
+
21
+
22
+ import { ID_RE, parseRange, privacySignalShare } from "@cloudhal/analytics";
23
+ import { previousRange } from "@cloudhal/analytics/service";
24
+ import { entryPath } from "@cloudhal/astro-renderer/routing";
25
+ import { archivePath } from "@cloudhal/astro-renderer/taxonomy";
26
+
27
+ import { termName } from "@cloudhal/core-ts";
28
+
29
+
30
+
31
+ /** A node of analytics rows as the current graph knows it. */
32
+
33
+
34
+
35
+
36
+
37
+
38
+
39
+
40
+
41
+
42
+
43
+
44
+
45
+ /** Where a combined answer came from (the REST envelope's `source`, `estimated`, `partial`). */
46
+
47
+
48
+
49
+
50
+
51
+
52
+ export function combineMeta(results ) {
53
+ const sources = new Set(results.map((r) => r.source));
54
+ const source =
55
+ sources.size === 1
56
+ ? ([...sources][0] )
57
+ : sources.size === 0
58
+ ? "live"
59
+ : "mixed";
60
+ const partial = results.some((r) => r.partial === true);
61
+ return {
62
+ source,
63
+ estimated: results.some((r) => r.estimated),
64
+ ...(partial ? { partial: true } : {}),
65
+ };
66
+ }
67
+
68
+ const KIND_BY_PREFIX = {
69
+ page: "page",
70
+ entry: "entry",
71
+ term: "archive",
72
+ };
73
+
74
+ export function nodeKindOf(id ) {
75
+ return KIND_BY_PREFIX[id.slice(0, id.indexOf("_"))] ?? null;
76
+ }
77
+
78
+ /** Individual reads up to this many entries; beyond it one list read (D1 caps queries per call). */
79
+ const ENTRY_GETS = 20;
80
+
81
+ /** The current graph's view of analytics node ids, in a bounded number of reads. */
82
+ export async function analyticsNodes(
83
+ store ,
84
+ site ,
85
+ ids ,
86
+ ) {
87
+ const unique = [...new Set(ids)].filter((id) => ID_RE.test(id));
88
+ const byKind = (k ) => unique.filter((id) => nodeKindOf(id) === k);
89
+ const out = new Map ();
90
+ const gone = (nodeId ) => ({
91
+ nodeId,
92
+ kind: nodeKindOf(nodeId),
93
+ exists: false,
94
+ title: null,
95
+ path: null,
96
+ status: null,
97
+ });
98
+
99
+ const pageIds = byKind("page");
100
+ if (pageIds.length > 0) {
101
+ const pages = new Map ((await store.listPages(site.id)).map((p) => [p.id, p]));
102
+ for (const id of pageIds) {
103
+ const p = pages.get(id);
104
+ out.set(
105
+ id,
106
+ p
107
+ ? {
108
+ nodeId: id,
109
+ kind: "page",
110
+ exists: true,
111
+ title: p.title,
112
+ path: p.path,
113
+ status: p.status,
114
+ }
115
+ : gone(id),
116
+ );
117
+ }
118
+ }
119
+
120
+ const entryIds = byKind("entry");
121
+ if (entryIds.length > 0) {
122
+ let entries ;
123
+ if (entryIds.length <= ENTRY_GETS) {
124
+ const found = await Promise.all(entryIds.map((id) => store.getContentEntry(id)));
125
+ entries = new Map(
126
+ found.filter((e) => e?.siteId === site.id).map((e) => [e.id, e]),
127
+ );
128
+ } else {
129
+ const wanted = new Set(entryIds);
130
+ entries = new Map(
131
+ (await store.listContentEntries(site.id))
132
+ .filter((e) => wanted.has(e.id))
133
+ .map((e) => [e.id, e]),
134
+ );
135
+ }
136
+ for (const id of entryIds) {
137
+ const e = entries.get(id);
138
+ out.set(
139
+ id,
140
+ e
141
+ ? {
142
+ nodeId: id,
143
+ kind: "entry",
144
+ exists: true,
145
+ title: e.title,
146
+ path: entryPath(e),
147
+ status: e.status,
148
+ }
149
+ : gone(id),
150
+ );
151
+ }
152
+ }
153
+
154
+ const termIds = byKind("archive");
155
+ if (termIds.length > 0) {
156
+ const [terms, taxonomies] = await Promise.all([
157
+ store.listTerms(site.id),
158
+ store.listTaxonomies(site.id),
159
+ ]);
160
+ const termById = new Map (terms.map((t) => [t.id, t]));
161
+ const taxById = new Map (taxonomies.map((t) => [t.id, t]));
162
+ for (const id of termIds) {
163
+ const t = termById.get(id);
164
+ const tax = t ? taxById.get(t.taxonomyId) : undefined;
165
+ out.set(
166
+ id,
167
+ t
168
+ ? {
169
+ nodeId: id,
170
+ kind: "archive",
171
+ exists: true,
172
+ title: termName(t, site.defaultLocale),
173
+ path: tax?.archive.enabled ? archivePath(tax, t.slug) : null,
174
+ status: t.status,
175
+ }
176
+ : gone(id),
177
+ );
178
+ }
179
+ }
180
+
181
+ for (const id of ids) if (!out.has(id)) out.set(id, gone(id));
182
+ return out;
183
+ }
184
+
185
+ // ───────────────────────────── summary ─────────────────────────────
186
+
187
+
188
+
189
+
190
+
191
+        
192
+
193
+
194
+
195
+
196
+
197
+  
198
+
199
+
200
+
201
+
202
+
203
+
204
+
205
+
206
+
207
+
208
+
209
+
210
+
211
+ const ratio = (cur , prev ) =>
212
+ cur === null || prev === null || prev === 0
213
+ ? null
214
+ : Math.round(((cur - prev) / prev) * 1000) / 1000;
215
+
216
+ export async function summaryReport(
217
+ analytics ,
218
+ siteId ,
219
+ range ,
220
+ options = {},
221
+ ) {
222
+ const filter = options.filter ? { filter: options.filter } : {};
223
+ const current = await analytics.query({ kind: "summary", siteId, range, ...filter });
224
+ const cur = current.data;
225
+ const base = {
226
+ ...cur,
227
+ viewsPerVisit: cur.visits > 0 ? Math.round((cur.pageviews / cur.visits) * 100) / 100 : null,
228
+ cacheHitRatio: analytics.cacheHitRatio(cur),
229
+ privacySignalShare: privacySignalShare(cur),
230
+ };
231
+ if (!options.compare) return { data: base, results: [current] };
232
+ const prevRange = previousRange(range);
233
+ const previous = await analytics.query({ kind: "summary", siteId, range: prevRange, ...filter });
234
+ const prev = previous.data;
235
+ return {
236
+ data: {
237
+ ...base,
238
+ previous: { ...prev, range: prevRange },
239
+ change: {
240
+ pageviews: ratio(cur.pageviews, prev.pageviews),
241
+ visits: ratio(cur.visits, prev.visits),
242
+ visitors: ratio(cur.visitors, prev.visitors),
243
+ },
244
+ },
245
+ results: [current, previous],
246
+ };
247
+ }
248
+
249
+ // ───────────────────────────── possible bot traffic ─────────────────────────────
250
+
251
+ /** A reason of the signal; `single_page` carries the page as the current graph knows it. */
252
+
253
+
254
+
255
+
256
+
257
+
258
+ /**
259
+ * The "possible bot traffic" signal (RFC-0006 §5.5) for REST `…/analytics/bots` and
260
+ * `get_analytics`: the period's last 7 days by the hour; a `single_page` reason is joined to the
261
+ * graph (title and current path), so people and agents see which page drew the burst.
262
+ */
263
+ export async function botTrafficReport(
264
+ analytics ,
265
+ store ,
266
+ site ,
267
+ range ,
268
+ ) {
269
+ const { signal, results } = await analytics.botTraffic(site.id, range);
270
+ const page = signal.reasons.find((r) => r.code === "single_page")?.key;
271
+ if (!page) return { data: signal, results };
272
+ const nodes = await analyticsNodes(store, site, [page]);
273
+ return {
274
+ data: {
275
+ ...signal,
276
+ reasons: signal.reasons.map((r) =>
277
+ r.code === "single_page" && r.key ? { ...r, node: nodes.get(r.key) } : r,
278
+ ),
279
+ },
280
+ results,
281
+ };
282
+ }
283
+
284
+ // ───────────────────────────── top lists ─────────────────────────────
285
+
286
+
287
+
288
+
289
+
290
+
291
+
292
+
293
+ /** Top content joined to the graph. `kind` and `channel` narrow it; `compare` adds the change. */
294
+ export async function topContentReport(
295
+ analytics ,
296
+ store ,
297
+ site ,
298
+ range ,
299
+ options
300
+
301
+
302
+
303
+
304
+ ,
305
+ ) {
306
+ // A kind filter is applied after the join, so ask for more rows than shown.
307
+ const ask = options.kind ? Math.min(100, options.limit * 4) : options.limit;
308
+ const filter = options.filter ? { filter: options.filter } : {};
309
+ const top = await analytics.query({
310
+ kind: "top",
311
+ siteId: site.id,
312
+ range,
313
+ dimension: "node",
314
+ limit: ask,
315
+ ...filter,
316
+ });
317
+ const results = [top];
318
+ let rows = top.data.rows.filter((r) => !options.kind || nodeKindOf(r.key) === options.kind);
319
+ rows = rows.slice(0, options.limit);
320
+ let before ;
321
+ if (options.compare && rows.length > 0) {
322
+ const prev = await analytics.query({
323
+ kind: "top",
324
+ siteId: site.id,
325
+ range: previousRange(range),
326
+ dimension: "node",
327
+ limit: Math.min(1000, ask * 4),
328
+ ...filter,
329
+ });
330
+ results.push(prev);
331
+ before = new Map(prev.data.rows.map((r) => [r.key, r.pageviews]));
332
+ }
333
+ const nodes = await analyticsNodes(
334
+ store,
335
+ site,
336
+ rows.map((r) => r.key),
337
+ );
338
+ return {
339
+ rows: rows.map((r) => ({
340
+ ...(nodes.get(r.key) ),
341
+ pageviews: r.pageviews,
342
+ visits: r.visits,
343
+ ...(before ? { change: ratio(r.pageviews, before.get(r.key) ?? 0) } : {}),
344
+ })),
345
+ results,
346
+ };
347
+ }
348
+
349
+ /** A plain top list (no join): channels, referrers, countries, devices, … */
350
+ export async function topList(
351
+ analytics ,
352
+ siteId ,
353
+ range ,
354
+ dimension ,
355
+ limit ,
356
+ filter ,
357
+ ) {
358
+ return analytics.query({
359
+ kind: "top",
360
+ siteId,
361
+ range,
362
+ dimension,
363
+ limit,
364
+ ...(filter ? { filter } : {}),
365
+ });
366
+ }
367
+
368
+ // ───────────────────────────── one node ─────────────────────────────
369
+
370
+
371
+
372
+
373
+
374
+
375
+
376
+
377
+
378
+
379
+
380
+
381
+
382
+
383
+
384
+
385
+
386
+
387
+
388
+
389
+
390
+
391
+
392
+
393
+
394
+
395
+
396
+
397
+
398
+
399
+ /** Hourly buckets for ranges of at most two days, daily otherwise (§10.1). */
400
+ export function seriesInterval(range ) {
401
+ const r = parseRange(range, 731);
402
+ return r.to.getTime() - r.from.getTime() <= 2 * 86_400_000 ? "hour" : "day";
403
+ }
404
+
405
+ export async function nodeReport(
406
+ analytics ,
407
+ store ,
408
+ site ,
409
+ nodeId ,
410
+ range ,
411
+ options = {},
412
+ ) {
413
+ const filter = { node: nodeId };
414
+ const interval = seriesInterval(range);
415
+ const [nodes, summary, series, channels, referrers, countries, devices, vitals, redirects] =
416
+ await Promise.all([
417
+ analyticsNodes(store, site, [nodeId]),
418
+ summaryReport(analytics, site.id, range, { filter, compare: options.compare }),
419
+ analytics.query({ kind: "series", siteId: site.id, range, interval, filter }),
420
+ topList(analytics, site.id, range, "channel", 10, filter),
421
+ topList(analytics, site.id, range, "refHost", 10, filter),
422
+ topList(analytics, site.id, range, "country", 10, filter),
423
+ topList(analytics, site.id, range, "device", 10, filter),
424
+ analytics.query({ kind: "vitals", siteId: site.id, range, filter }),
425
+ analytics.query({
426
+ kind: "renders",
427
+ siteId: site.id,
428
+ range,
429
+ statuses: "redirects",
430
+ limit: 1000,
431
+ }),
432
+ ]);
433
+ const redirectRows = redirects.data.rows ;
434
+ const history = (await store.listRedirects(site.id)).filter((r) => r.source?.id === nodeId);
435
+ const hitsFrom = (path ) =>
436
+ redirectRows.filter((r) => r.path === path).reduce((n, r) => n + r.hits, 0);
437
+ const oldPaths = history.map((r) => ({
438
+ from: r.from,
439
+ to: r.to,
440
+ status: r.status,
441
+ reason: r.reason,
442
+ hits: hitsFrom(r.from),
443
+ }));
444
+ const froms = new Set(history.map((r) => r.from));
445
+ const redirectHits = redirectRows
446
+ .filter((r) => r.nodeId === nodeId || froms.has(r.path))
447
+ .reduce((n, r) => n + r.hits, 0);
448
+ return {
449
+ data: {
450
+ node: nodes.get(nodeId) ,
451
+ summary: summary.data,
452
+ series: series.data,
453
+ channels: channels.data.rows,
454
+ referrers: referrers.data.rows,
455
+ countries: countries.data.rows,
456
+ devices: devices.data.rows,
457
+ vitals: vitals.data,
458
+ oldPaths,
459
+ redirectHits,
460
+ },
461
+ meta: combineMeta([
462
+ ...summary.results,
463
+ series,
464
+ channels,
465
+ referrers,
466
+ countries,
467
+ devices,
468
+ vitals,
469
+ redirects,
470
+ ]),
471
+ };
472
+ }
473
+
474
+ // ───────────────────────────── broken links ─────────────────────────────
475
+
476
+
477
+  
478
+
479
+
480
+
481
+
482
+
483
+
484
+
485
+
486
+
487
+
488
+
489
+
490
+
491
+
492
+ function lastSegment(path ) {
493
+ let p = path;
494
+ try {
495
+ p = decodeURIComponent(path);
496
+ } catch {
497
+ // keep it encoded
498
+ }
499
+ const parts = p
500
+ .toLowerCase()
501
+ .replace(/[?#].*$/, "")
502
+ .split("/")
503
+ .filter(Boolean);
504
+ return parts.at(-1) ?? "";
505
+ }
506
+
507
+ function editDistance(a , b , max ) {
508
+ if (Math.abs(a.length - b.length) > max) return max + 1;
509
+ let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
510
+ for (let i = 1; i <= a.length; i++) {
511
+ const cur = [i];
512
+ let best = i;
513
+ for (let j = 1; j <= b.length; j++) {
514
+ const v = Math.min(
515
+ (prev[j] ) + 1,
516
+ (cur[j - 1] ) + 1,
517
+ (prev[j - 1] ) + (a[i - 1] === b[j - 1] ? 0 : 1),
518
+ );
519
+ cur.push(v);
520
+ best = Math.min(best, v);
521
+ }
522
+ if (best > max) return max + 1;
523
+ prev = cur;
524
+ }
525
+ return prev[b.length] ;
526
+ }
527
+
528
+ /** 404 and 410 paths with hits, the pages linking to them, and a suggested target. */
529
+ export async function brokenLinksReport(
530
+ analytics ,
531
+ store ,
532
+ site ,
533
+ range ,
534
+ limit ,
535
+ ) {
536
+ const errors = await analytics.query({
537
+ kind: "renders",
538
+ siteId: site.id,
539
+ range,
540
+ statuses: "errors",
541
+ limit: 1000,
542
+ });
543
+ const groups = new Map
544
+
545
+
546
+
547
+
548
+
549
+
550
+
551
+
552
+ ();
553
+ for (const r of errors.data.rows ) {
554
+ if (r.status !== 404 && r.status !== 410) continue;
555
+ const key = `${r.status} ${r.path}`;
556
+ const g = groups.get(key) ?? {
557
+ path: r.path,
558
+ status: r.status,
559
+ hits: 0,
560
+ refs: new Map (),
561
+ lastSeen: null,
562
+ };
563
+ g.hits += r.hits;
564
+ if (r.refPath) g.refs.set(r.refPath, (g.refs.get(r.refPath) ?? 0) + r.hits);
565
+ if (r.lastSeen && (!g.lastSeen || r.lastSeen > g.lastSeen)) g.lastSeen = r.lastSeen;
566
+ groups.set(key, g);
567
+ }
568
+ const top = [...groups.values()]
569
+ .sort((a, b) => b.hits - a.hits || (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
570
+ .slice(0, limit);
571
+
572
+ // Candidates for "similar slug": published pages and entries (two reads).
573
+ let candidates ;
574
+ const loadCandidates = async () => {
575
+ if (candidates) return candidates;
576
+ const [pages, entries] = await Promise.all([
577
+ store.listPages(site.id),
578
+ store.listContentEntries(site.id, { status: ["published"] }),
579
+ ]);
580
+ candidates = [
581
+ ...pages
582
+ .filter((p) => p.status === "published")
583
+ .map((p) => ({ path: p.path, nodeId: p.id, segment: lastSegment(p.path) })),
584
+ ...entries.map((e) => ({ path: entryPath(e), nodeId: e.id, segment: e.slug })),
585
+ ];
586
+ return candidates;
587
+ };
588
+
589
+ const rows = [];
590
+ for (const g of top) {
591
+ let suggestedTarget = null;
592
+ let decoded ;
593
+ try {
594
+ decoded = decodeURIComponent(g.path).toLowerCase();
595
+ } catch {
596
+ decoded = undefined;
597
+ }
598
+ const match = decoded
599
+ ? await store.redirectFor(site.id, decoded).catch(() => undefined)
600
+ : undefined;
601
+ if (match) suggestedTarget = { path: match.location, reason: "redirect" };
602
+ else {
603
+ const seg = lastSegment(g.path);
604
+ if (seg.length >= 3) {
605
+ let best ;
606
+ for (const c of await loadCandidates()) {
607
+ if (!c.segment) continue;
608
+ const d =
609
+ c.segment === seg
610
+ ? 0
611
+ : c.segment.includes(seg) || seg.includes(c.segment)
612
+ ? 1
613
+ : editDistance(seg, c.segment, 3);
614
+ if (d <= 3 && (!best || d < best.d)) best = { path: c.path, nodeId: c.nodeId, d };
615
+ }
616
+ if (best) suggestedTarget = { path: best.path, nodeId: best.nodeId, reason: "similar" };
617
+ }
618
+ }
619
+ rows.push({
620
+ path: g.path,
621
+ status: g.status,
622
+ hits: g.hits,
623
+ linkedFrom: [...g.refs.entries()]
624
+ .sort((a, b) => b[1] - a[1])
625
+ .slice(0, 5)
626
+ .map(([p]) => p),
627
+ lastSeen: g.lastSeen,
628
+ suggestedTarget,
629
+ });
630
+ }
631
+ return { rows, meta: combineMeta([errors]) };
632
+ }
@@ -0,0 +1,4 @@
1
+ import { type ToolDefinition } from "./common.js";
2
+ /** Analytics errors as tool errors with stable codes. */
3
+ export declare function toAnalyticsToolError(e: unknown): unknown;
4
+ export declare function analyticsTools(): ToolDefinition[];