@farming-labs/theme 0.1.60 → 0.1.63

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/docs-api.mjs CHANGED
@@ -3,7 +3,7 @@ import { getNextAppDir } from "./get-app-dir.mjs";
3
3
  import fs from "node:fs";
4
4
  import path from "node:path";
5
5
  import matter from "gray-matter";
6
- import { normalizeDocsRelated, performDocsSearch, renderDocsRelatedMarkdownLines, resolveChangelogConfig, resolveDocsI18n, resolveDocsLocale, resolveSearchRequestConfig } from "@farming-labs/docs";
6
+ import { emitDocsAnalyticsEvent, normalizeDocsRelated, performDocsSearch, renderDocsRelatedMarkdownLines, resolveChangelogConfig, resolveDocsI18n, resolveDocsLocale, resolveSearchRequestConfig } from "@farming-labs/docs";
7
7
  import { createDocsMcpHttpHandler, createFilesystemDocsMcpSource, resolveDocsMcpConfig } from "@farming-labs/docs/server";
8
8
 
9
9
  //#region src/docs-api.ts
@@ -805,15 +805,30 @@ function acceptsMarkdown(request) {
805
805
  });
806
806
  }
807
807
  function resolveMarkdownRequest(entry, url, request) {
808
- if (url.searchParams.get("format")?.trim() === "markdown") return { requestedPath: url.searchParams.get("path")?.trim() ?? "" };
808
+ if (url.searchParams.get("format")?.trim() === "markdown") return {
809
+ requestedPath: url.searchParams.get("path")?.trim() ?? "",
810
+ delivery: "api_format"
811
+ };
809
812
  const pathname = normalizeUrlPath(url.pathname);
810
813
  const normalizedEntry = `/${normalizePathSegment(entry)}`;
811
- if (pathname === `${normalizedEntry}.md`) return { requestedPath: "" };
814
+ if (pathname === `${normalizedEntry}.md`) return {
815
+ requestedPath: "",
816
+ delivery: "md_route"
817
+ };
812
818
  const slugPrefix = `${normalizedEntry}/`;
813
- if (pathname.startsWith(slugPrefix) && pathname.endsWith(".md")) return { requestedPath: pathname.slice(slugPrefix.length, -3) };
819
+ if (pathname.startsWith(slugPrefix) && pathname.endsWith(".md")) return {
820
+ requestedPath: pathname.slice(slugPrefix.length, -3),
821
+ delivery: "md_route"
822
+ };
814
823
  if (acceptsMarkdown(request)) {
815
- if (pathname === normalizedEntry) return { requestedPath: "" };
816
- if (pathname.startsWith(slugPrefix)) return { requestedPath: pathname.slice(slugPrefix.length) };
824
+ if (pathname === normalizedEntry) return {
825
+ requestedPath: "",
826
+ delivery: "accept_header"
827
+ };
828
+ if (pathname.startsWith(slugPrefix)) return {
829
+ requestedPath: pathname.slice(slugPrefix.length),
830
+ delivery: "accept_header"
831
+ };
817
832
  }
818
833
  return null;
819
834
  }
@@ -900,20 +915,60 @@ function resolveModelAndProvider(aiConfig, requestedModelId) {
900
915
  apiKey
901
916
  };
902
917
  }
903
- async function handleAskAI(request, indexes, aiConfig) {
918
+ async function handleAskAI(request, indexes, aiConfig, analytics, analyticsContext = {}) {
919
+ const url = new URL(request.url);
920
+ const requestStartedAt = Date.now();
904
921
  let body;
905
922
  try {
906
923
  body = await request.json();
907
924
  } catch {
925
+ await emitDocsAnalyticsEvent(analytics, {
926
+ type: "api_ai_error",
927
+ source: "server",
928
+ url: request.url,
929
+ path: url.pathname,
930
+ locale: analyticsContext.locale,
931
+ properties: {
932
+ reason: "invalid_json",
933
+ durationMs: Math.max(0, Date.now() - requestStartedAt)
934
+ }
935
+ });
908
936
  return Response.json({ error: "Invalid JSON body. Expected { messages: [...] }" }, { status: 400 });
909
937
  }
910
938
  const messages = body.messages;
911
- if (!Array.isArray(messages) || messages.length === 0) return Response.json({ error: "messages array is required and must not be empty." }, { status: 400 });
939
+ if (!Array.isArray(messages) || messages.length === 0) {
940
+ await emitDocsAnalyticsEvent(analytics, {
941
+ type: "api_ai_error",
942
+ source: "server",
943
+ url: request.url,
944
+ path: url.pathname,
945
+ locale: analyticsContext.locale,
946
+ properties: {
947
+ reason: "missing_messages",
948
+ durationMs: Math.max(0, Date.now() - requestStartedAt)
949
+ }
950
+ });
951
+ return Response.json({ error: "messages array is required and must not be empty." }, { status: 400 });
952
+ }
912
953
  const lastUserMessage = [...messages].reverse().find((m) => m.role === "user");
913
- if (!lastUserMessage) return Response.json({ error: "At least one user message is required." }, { status: 400 });
954
+ if (!lastUserMessage) {
955
+ await emitDocsAnalyticsEvent(analytics, {
956
+ type: "api_ai_error",
957
+ source: "server",
958
+ url: request.url,
959
+ path: url.pathname,
960
+ locale: analyticsContext.locale,
961
+ properties: {
962
+ reason: "missing_user_message",
963
+ messageCount: messages.length,
964
+ durationMs: Math.max(0, Date.now() - requestStartedAt)
965
+ }
966
+ });
967
+ return Response.json({ error: "At least one user message is required." }, { status: 400 });
968
+ }
914
969
  const maxResults = aiConfig.maxResults ?? 5;
915
970
  const query = lastUserMessage.content;
916
- const context = indexes.map((doc) => {
971
+ const scored = indexes.map((doc) => {
917
972
  const q = query.toLowerCase();
918
973
  const titleMatch = doc.title.toLowerCase().includes(q) ? 10 : 0;
919
974
  const contentMatch = q.split(/\s+/).reduce((score, word) => {
@@ -923,14 +978,47 @@ async function handleAskAI(request, indexes, aiConfig) {
923
978
  ...doc,
924
979
  score: titleMatch + contentMatch
925
980
  };
926
- }).filter((d) => d.score > 0).sort((a, b) => b.score - a.score).slice(0, maxResults).map((doc) => `## ${doc.title}\nURL: ${doc.url}\n${doc.description ? `Description: ${doc.description}\n` : ""}\n${doc.content}`).join("\n\n---\n\n");
981
+ }).filter((d) => d.score > 0).sort((a, b) => b.score - a.score).slice(0, maxResults);
982
+ const context = scored.map((doc) => `## ${doc.title}\nURL: ${doc.url}\n${doc.description ? `Description: ${doc.description}\n` : ""}\n${doc.content}`).join("\n\n---\n\n");
927
983
  const systemPrompt = aiConfig.systemPrompt ?? DEFAULT_SYSTEM_PROMPT;
928
984
  const llmMessages = [{
929
985
  role: "system",
930
986
  content: context ? `${systemPrompt}\n\n---\n\nDocumentation context:\n\n${context}` : systemPrompt
931
987
  }, ...messages.filter((m) => m.role !== "system")];
932
988
  const resolved = resolveModelAndProvider(aiConfig, typeof body.model === "string" && body.model.trim().length > 0 ? body.model.trim() : void 0);
933
- if (!resolved.apiKey) return Response.json({ error: `AI is enabled but no API key was found. Either set apiKey in your docs.config ai section, configure a provider, or add OPENAI_API_KEY to your .env.local file.` }, { status: 500 });
989
+ if (!resolved.apiKey) {
990
+ await emitDocsAnalyticsEvent(analytics, {
991
+ type: "api_ai_error",
992
+ source: "server",
993
+ url: request.url,
994
+ path: url.pathname,
995
+ locale: analyticsContext.locale,
996
+ input: { question: query },
997
+ properties: {
998
+ reason: "missing_api_key",
999
+ messageCount: messages.length,
1000
+ questionLength: query.length,
1001
+ retrievedCount: scored.length,
1002
+ model: resolved.model,
1003
+ durationMs: Math.max(0, Date.now() - requestStartedAt)
1004
+ }
1005
+ });
1006
+ return Response.json({ error: `AI is enabled but no API key was found. Either set apiKey in your docs.config ai section, configure a provider, or add OPENAI_API_KEY to your .env.local file.` }, { status: 500 });
1007
+ }
1008
+ await emitDocsAnalyticsEvent(analytics, {
1009
+ type: "api_ai_request",
1010
+ source: "server",
1011
+ url: request.url,
1012
+ path: url.pathname,
1013
+ locale: analyticsContext.locale,
1014
+ input: { question: query },
1015
+ properties: {
1016
+ messageCount: messages.length,
1017
+ questionLength: query.length,
1018
+ retrievedCount: scored.length,
1019
+ model: resolved.model
1020
+ }
1021
+ });
934
1022
  const llmResponse = await fetch(`${resolved.baseUrl}/chat/completions`, {
935
1023
  method: "POST",
936
1024
  headers: {
@@ -945,8 +1033,40 @@ async function handleAskAI(request, indexes, aiConfig) {
945
1033
  });
946
1034
  if (!llmResponse.ok) {
947
1035
  const errText = await llmResponse.text().catch(() => "Unknown error");
1036
+ await emitDocsAnalyticsEvent(analytics, {
1037
+ type: "api_ai_error",
1038
+ source: "server",
1039
+ url: request.url,
1040
+ path: url.pathname,
1041
+ locale: analyticsContext.locale,
1042
+ input: { question: query },
1043
+ properties: {
1044
+ reason: "llm_error",
1045
+ status: llmResponse.status,
1046
+ messageCount: messages.length,
1047
+ questionLength: query.length,
1048
+ retrievedCount: scored.length,
1049
+ model: resolved.model,
1050
+ durationMs: Math.max(0, Date.now() - requestStartedAt)
1051
+ }
1052
+ });
948
1053
  return Response.json({ error: `LLM API error (${llmResponse.status}): ${errText}` }, { status: 502 });
949
1054
  }
1055
+ await emitDocsAnalyticsEvent(analytics, {
1056
+ type: "api_ai_response",
1057
+ source: "server",
1058
+ url: request.url,
1059
+ path: url.pathname,
1060
+ locale: analyticsContext.locale,
1061
+ input: { question: query },
1062
+ properties: {
1063
+ messageCount: messages.length,
1064
+ questionLength: query.length,
1065
+ retrievedCount: scored.length,
1066
+ model: resolved.model,
1067
+ durationMs: Math.max(0, Date.now() - requestStartedAt)
1068
+ }
1069
+ });
950
1070
  return new Response(llmResponse.body, { headers: {
951
1071
  "Content-Type": "text/event-stream",
952
1072
  "Cache-Control": "no-cache",
@@ -1026,6 +1146,7 @@ function generateLlmsTxt(indexes, options) {
1026
1146
  function createDocsAPI(options) {
1027
1147
  const root = options?.rootDir ?? process.cwd();
1028
1148
  const entry = options?.entry ?? readEntry(root);
1149
+ const analytics = options?.analytics;
1029
1150
  const appDir = getNextAppDir(root);
1030
1151
  const contentDir = options?.contentDir ?? path.join(appDir, entry);
1031
1152
  const changelogConfig = resolveChangelogConfig(options?.changelog);
@@ -1154,51 +1275,131 @@ function createDocsAPI(options) {
1154
1275
  async GET(request) {
1155
1276
  const ctx = resolveContextFromRequest(request);
1156
1277
  const url = new URL(request.url);
1157
- if (resolveAgentSpecRequest(url)) return Response.json(buildAgentSpec({
1158
- origin: url.origin,
1159
- entry,
1160
- i18n,
1161
- search: searchConfig,
1162
- mcp: mcpConfig,
1163
- feedback: agentFeedbackConfig,
1164
- llms: llmsConfig
1165
- }), { headers: {
1166
- "Cache-Control": "public, max-age=0, s-maxage=3600",
1167
- "X-Robots-Tag": "noindex"
1168
- } });
1278
+ if (resolveAgentSpecRequest(url)) {
1279
+ await emitDocsAnalyticsEvent(analytics, {
1280
+ type: "agent_spec_request",
1281
+ source: "server",
1282
+ url: request.url,
1283
+ path: url.pathname,
1284
+ locale: ctx.locale,
1285
+ properties: { method: "GET" }
1286
+ });
1287
+ return Response.json(buildAgentSpec({
1288
+ origin: url.origin,
1289
+ entry,
1290
+ i18n,
1291
+ search: searchConfig,
1292
+ mcp: mcpConfig,
1293
+ feedback: agentFeedbackConfig,
1294
+ llms: llmsConfig
1295
+ }), { headers: {
1296
+ "Cache-Control": "public, max-age=0, s-maxage=3600",
1297
+ "X-Robots-Tag": "noindex"
1298
+ } });
1299
+ }
1169
1300
  const agentFeedbackRequest = resolveAgentFeedbackRequest(url, agentFeedbackConfig);
1170
1301
  if (agentFeedbackRequest) {
1171
1302
  if (agentFeedbackRequest.kind === "submit") return Response.json({ error: "Method Not Allowed" }, {
1172
1303
  status: 405,
1173
1304
  headers: { Allow: "POST" }
1174
1305
  });
1306
+ await emitDocsAnalyticsEvent(analytics, {
1307
+ type: "agent_feedback_schema",
1308
+ source: "server",
1309
+ url: request.url,
1310
+ path: url.pathname,
1311
+ locale: ctx.locale,
1312
+ properties: { method: "GET" }
1313
+ });
1175
1314
  return new Response(JSON.stringify(agentFeedbackConfig.schema, null, 2), { headers: {
1176
1315
  "Content-Type": "application/schema+json; charset=utf-8",
1177
1316
  "Cache-Control": "public, max-age=0, s-maxage=3600",
1178
1317
  "X-Robots-Tag": "noindex"
1179
1318
  } });
1180
1319
  }
1181
- if (resolveSkillRequest(url)) return new Response(readRootSkillDocument(root) ?? renderSkillDocument({
1182
- origin: url.origin,
1183
- entry,
1184
- i18n,
1185
- search: searchConfig,
1186
- mcp: mcpConfig,
1187
- feedback: agentFeedbackConfig,
1188
- llms: llmsConfig
1189
- }), { headers: {
1190
- "Content-Type": "text/markdown; charset=utf-8",
1191
- "Cache-Control": "public, max-age=0, s-maxage=3600",
1192
- "X-Robots-Tag": "noindex"
1193
- } });
1320
+ if (resolveSkillRequest(url)) {
1321
+ await emitDocsAnalyticsEvent(analytics, {
1322
+ type: "skill_request",
1323
+ source: "server",
1324
+ url: request.url,
1325
+ path: url.pathname,
1326
+ locale: ctx.locale,
1327
+ properties: { method: "GET" }
1328
+ });
1329
+ return new Response(readRootSkillDocument(root) ?? renderSkillDocument({
1330
+ origin: url.origin,
1331
+ entry,
1332
+ i18n,
1333
+ search: searchConfig,
1334
+ mcp: mcpConfig,
1335
+ feedback: agentFeedbackConfig,
1336
+ llms: llmsConfig
1337
+ }), { headers: {
1338
+ "Content-Type": "text/markdown; charset=utf-8",
1339
+ "Cache-Control": "public, max-age=0, s-maxage=3600",
1340
+ "X-Robots-Tag": "noindex"
1341
+ } });
1342
+ }
1194
1343
  const markdownRequest = resolveMarkdownRequest(entry, url, request);
1195
1344
  if (markdownRequest) {
1196
1345
  const document = await getMarkdownDocument(ctx, markdownRequest.requestedPath);
1197
- if (!document) return new Response("Not Found", {
1198
- status: 404,
1199
- headers: {
1200
- "Content-Type": "text/plain; charset=utf-8",
1201
- "X-Robots-Tag": "noindex"
1346
+ if (!document) {
1347
+ await emitDocsAnalyticsEvent(analytics, {
1348
+ type: "agent_read",
1349
+ source: "server",
1350
+ url: request.url,
1351
+ path: url.pathname,
1352
+ locale: ctx.locale,
1353
+ properties: {
1354
+ requestedPath: markdownRequest.requestedPath,
1355
+ delivery: markdownRequest.delivery,
1356
+ found: false
1357
+ }
1358
+ });
1359
+ await emitDocsAnalyticsEvent(analytics, {
1360
+ type: "markdown_request",
1361
+ source: "server",
1362
+ url: request.url,
1363
+ path: url.pathname,
1364
+ locale: ctx.locale,
1365
+ properties: {
1366
+ requestedPath: markdownRequest.requestedPath,
1367
+ delivery: markdownRequest.delivery,
1368
+ found: false
1369
+ }
1370
+ });
1371
+ return new Response("Not Found", {
1372
+ status: 404,
1373
+ headers: {
1374
+ "Content-Type": "text/plain; charset=utf-8",
1375
+ "X-Robots-Tag": "noindex"
1376
+ }
1377
+ });
1378
+ }
1379
+ await emitDocsAnalyticsEvent(analytics, {
1380
+ type: "agent_read",
1381
+ source: "server",
1382
+ url: request.url,
1383
+ path: url.pathname,
1384
+ locale: ctx.locale,
1385
+ properties: {
1386
+ requestedPath: markdownRequest.requestedPath,
1387
+ delivery: markdownRequest.delivery,
1388
+ found: true,
1389
+ contentLength: document.length
1390
+ }
1391
+ });
1392
+ await emitDocsAnalyticsEvent(analytics, {
1393
+ type: "markdown_request",
1394
+ source: "server",
1395
+ url: request.url,
1396
+ path: url.pathname,
1397
+ locale: ctx.locale,
1398
+ properties: {
1399
+ requestedPath: markdownRequest.requestedPath,
1400
+ delivery: markdownRequest.delivery,
1401
+ found: true,
1402
+ contentLength: document.length
1202
1403
  }
1203
1404
  });
1204
1405
  return new Response(document, { headers: {
@@ -1208,16 +1409,37 @@ function createDocsAPI(options) {
1208
1409
  } });
1209
1410
  }
1210
1411
  const llmsFormat = resolveLlmsTxtFormat(url);
1211
- if (llmsFormat === "llms") return new Response(getLlmsContent(ctx).llmsTxt, { headers: {
1212
- "Content-Type": "text/plain; charset=utf-8",
1213
- "Cache-Control": "public, max-age=3600"
1214
- } });
1215
- if (llmsFormat === "llms-full") return new Response(getLlmsContent(ctx).llmsFullTxt, { headers: {
1216
- "Content-Type": "text/plain; charset=utf-8",
1217
- "Cache-Control": "public, max-age=3600"
1218
- } });
1412
+ if (llmsFormat === "llms") {
1413
+ await emitDocsAnalyticsEvent(analytics, {
1414
+ type: "llms_request",
1415
+ source: "server",
1416
+ url: request.url,
1417
+ path: url.pathname,
1418
+ locale: ctx.locale,
1419
+ properties: { format: "llms" }
1420
+ });
1421
+ return new Response(getLlmsContent(ctx).llmsTxt, { headers: {
1422
+ "Content-Type": "text/plain; charset=utf-8",
1423
+ "Cache-Control": "public, max-age=3600"
1424
+ } });
1425
+ }
1426
+ if (llmsFormat === "llms-full") {
1427
+ await emitDocsAnalyticsEvent(analytics, {
1428
+ type: "llms_request",
1429
+ source: "server",
1430
+ url: request.url,
1431
+ path: url.pathname,
1432
+ locale: ctx.locale,
1433
+ properties: { format: "llms-full" }
1434
+ });
1435
+ return new Response(getLlmsContent(ctx).llmsFullTxt, { headers: {
1436
+ "Content-Type": "text/plain; charset=utf-8",
1437
+ "Cache-Control": "public, max-age=3600"
1438
+ } });
1439
+ }
1219
1440
  const query = url.searchParams.get("query")?.trim();
1220
1441
  if (!query) return new Response(JSON.stringify([]), { headers: { "Content-Type": "application/json" } });
1442
+ const searchStartedAt = Date.now();
1221
1443
  const results = await performDocsSearch({
1222
1444
  pages: getIndexes(ctx),
1223
1445
  query,
@@ -1226,31 +1448,101 @@ function createDocsAPI(options) {
1226
1448
  pathname: url.searchParams.get("pathname") ?? void 0,
1227
1449
  siteTitle: llmsConfig.siteTitle ?? "Documentation"
1228
1450
  });
1451
+ await emitDocsAnalyticsEvent(analytics, {
1452
+ type: "api_search",
1453
+ source: "server",
1454
+ url: request.url,
1455
+ path: url.pathname,
1456
+ locale: ctx.locale,
1457
+ input: { query },
1458
+ properties: {
1459
+ queryLength: query.length,
1460
+ resultCount: results.length,
1461
+ pathname: url.searchParams.get("pathname") ?? void 0,
1462
+ durationMs: Math.max(0, Date.now() - searchStartedAt)
1463
+ }
1464
+ });
1229
1465
  return new Response(JSON.stringify(results), { headers: { "Content-Type": "application/json" } });
1230
1466
  },
1231
1467
  async POST(request) {
1232
- const agentFeedbackRequest = resolveAgentFeedbackRequest(new URL(request.url), agentFeedbackConfig);
1468
+ const url = new URL(request.url);
1469
+ const agentFeedbackRequest = resolveAgentFeedbackRequest(url, agentFeedbackConfig);
1233
1470
  if (agentFeedbackRequest) {
1234
1471
  if (agentFeedbackRequest.kind === "schema") return Response.json({ error: "Method Not Allowed" }, {
1235
1472
  status: 405,
1236
1473
  headers: { Allow: "GET" }
1237
1474
  });
1238
1475
  const parsed = await parseAgentFeedbackData(request);
1239
- if (!parsed.ok) return parsed.response;
1476
+ if (!parsed.ok) {
1477
+ await emitDocsAnalyticsEvent(analytics, {
1478
+ type: "agent_feedback_error",
1479
+ source: "server",
1480
+ url: request.url,
1481
+ path: url.pathname,
1482
+ properties: { reason: "invalid_body" }
1483
+ });
1484
+ return parsed.response;
1485
+ }
1240
1486
  const payloadError = validateAgentFeedbackPayload(parsed.data.payload, agentFeedbackConfig.payloadSchema);
1241
- if (payloadError) return Response.json({ error: payloadError }, { status: 400 });
1242
- if (!agentFeedbackConfig.onFeedback) return Response.json({
1243
- ok: true,
1244
- handled: false
1245
- }, { status: 202 });
1487
+ if (payloadError) {
1488
+ await emitDocsAnalyticsEvent(analytics, {
1489
+ type: "agent_feedback_error",
1490
+ source: "server",
1491
+ url: request.url,
1492
+ path: url.pathname,
1493
+ properties: {
1494
+ reason: "invalid_payload",
1495
+ error: payloadError
1496
+ }
1497
+ });
1498
+ return Response.json({ error: payloadError }, { status: 400 });
1499
+ }
1500
+ if (!agentFeedbackConfig.onFeedback) {
1501
+ await emitDocsAnalyticsEvent(analytics, {
1502
+ type: "agent_feedback_submit",
1503
+ source: "server",
1504
+ url: request.url,
1505
+ path: url.pathname,
1506
+ properties: {
1507
+ handled: false,
1508
+ payloadKeys: Object.keys(parsed.data.payload),
1509
+ hasContext: Boolean(parsed.data.context)
1510
+ }
1511
+ });
1512
+ return Response.json({
1513
+ ok: true,
1514
+ handled: false
1515
+ }, { status: 202 });
1516
+ }
1246
1517
  await agentFeedbackConfig.onFeedback(parsed.data);
1518
+ await emitDocsAnalyticsEvent(analytics, {
1519
+ type: "agent_feedback_submit",
1520
+ source: "server",
1521
+ url: request.url,
1522
+ path: url.pathname,
1523
+ properties: {
1524
+ handled: true,
1525
+ payloadKeys: Object.keys(parsed.data.payload),
1526
+ hasContext: Boolean(parsed.data.context)
1527
+ }
1528
+ });
1247
1529
  return Response.json({
1248
1530
  ok: true,
1249
1531
  handled: true
1250
1532
  }, { status: 201 });
1251
1533
  }
1252
- if (!aiConfig.enabled) return Response.json({ error: "AI is not enabled. Set `ai: { enabled: true }` in your docs.config to enable it." }, { status: 404 });
1253
- return handleAskAI(request, getIndexes(resolveContextFromRequest(request)), aiConfig);
1534
+ if (!aiConfig.enabled) {
1535
+ await emitDocsAnalyticsEvent(analytics, {
1536
+ type: "api_ai_error",
1537
+ source: "server",
1538
+ url: request.url,
1539
+ path: url.pathname,
1540
+ properties: { reason: "disabled" }
1541
+ });
1542
+ return Response.json({ error: "AI is not enabled. Set `ai: { enabled: true }` in your docs.config to enable it." }, { status: 404 });
1543
+ }
1544
+ const ctx = resolveContextFromRequest(request);
1545
+ return handleAskAI(request, getIndexes(ctx), aiConfig, analytics, { locale: ctx.locale });
1254
1546
  }
1255
1547
  };
1256
1548
  }
@@ -1279,6 +1571,7 @@ function createDocsMCPAPI(options = {}) {
1279
1571
  }),
1280
1572
  mcp: options.mcp ?? readMcpConfig(rootDir),
1281
1573
  search: options.search,
1574
+ analytics: options.analytics,
1282
1575
  defaultName: navTitle
1283
1576
  });
1284
1577
  return {
@@ -1,14 +1,16 @@
1
- import { CodeBlockCopyData, DocsFeedbackData } from "@farming-labs/docs";
1
+ import { CodeBlockCopyData, DocsAnalyticsConfig, DocsFeedbackData } from "@farming-labs/docs";
2
2
 
3
3
  //#region src/docs-client-hooks.d.ts
4
4
  type CopyHandler = (data: CodeBlockCopyData) => void;
5
5
  type FeedbackHandler = (data: DocsFeedbackData) => void | Promise<void>;
6
6
  declare function DocsClientHooks({
7
7
  onCopyClick,
8
- onFeedback
8
+ onFeedback,
9
+ analytics
9
10
  }: {
10
11
  onCopyClick?: CopyHandler;
11
12
  onFeedback?: FeedbackHandler;
13
+ analytics?: boolean | DocsAnalyticsConfig;
12
14
  }): null;
13
15
  //#endregion
14
16
  export { DocsClientHooks };
@@ -1,6 +1,8 @@
1
1
  "use client";
2
2
 
3
+ import { emitClientAnalyticsEvent } from "./client-analytics.mjs";
3
4
  import { useEffect } from "react";
5
+ import { emitDocsAnalyticsEvent } from "@farming-labs/docs";
4
6
 
5
7
  //#region src/docs-client-hooks.tsx
6
8
  function useWindowHook(key, handler) {
@@ -16,9 +18,58 @@ function useWindowHook(key, handler) {
16
18
  };
17
19
  }, [handler, key]);
18
20
  }
19
- function DocsClientHooks({ onCopyClick, onFeedback }) {
21
+ function useAnalyticsHook(analytics) {
22
+ useEffect(() => {
23
+ if (typeof window === "undefined") return;
24
+ if (!analytics) return;
25
+ const target = window;
26
+ const handler = (event) => {
27
+ emitDocsAnalyticsEvent(analytics, event);
28
+ };
29
+ const previous = target.__fdAnalytics__;
30
+ target.__fdAnalytics__ = handler;
31
+ const queued = target.__fdAnalyticsQueue__ ?? [];
32
+ delete target.__fdAnalyticsQueue__;
33
+ for (const event of queued) handler(event);
34
+ return () => {
35
+ if (target.__fdAnalytics__ === handler) if (typeof previous === "function") target.__fdAnalytics__ = previous;
36
+ else delete target.__fdAnalytics__;
37
+ };
38
+ }, [analytics]);
39
+ }
40
+ function useCodeCopyAnalytics(analytics) {
41
+ useEffect(() => {
42
+ if (typeof window === "undefined") return;
43
+ if (!analytics) return;
44
+ const handleClick = (event) => {
45
+ const target = event.target;
46
+ if (!target.closest?.("button")) return;
47
+ const figure = target.closest("figure");
48
+ if (!figure) return;
49
+ const code = figure.querySelector("pre code");
50
+ if (!code) return;
51
+ const content = code.textContent ?? "";
52
+ const language = code.getAttribute("data-language") ?? figure.getAttribute("data-language") ?? void 0;
53
+ const title = figure.querySelector("[data-title]")?.textContent?.trim() ?? figure.querySelector(".fd-codeblock-title-text")?.textContent?.trim() ?? void 0;
54
+ emitClientAnalyticsEvent({
55
+ type: "code_block_copy",
56
+ input: { content },
57
+ properties: {
58
+ title,
59
+ language,
60
+ contentLength: content.length
61
+ }
62
+ });
63
+ };
64
+ document.addEventListener("click", handleClick, true);
65
+ return () => document.removeEventListener("click", handleClick, true);
66
+ }, [analytics]);
67
+ }
68
+ function DocsClientHooks({ onCopyClick, onFeedback, analytics }) {
20
69
  useWindowHook("__fdOnCopyClick__", onCopyClick);
21
70
  useWindowHook("__fdOnFeedback__", onFeedback);
71
+ useAnalyticsHook(analytics);
72
+ useCodeCopyAnalytics(analytics);
22
73
  return null;
23
74
  }
24
75
 
@@ -9,10 +9,12 @@ import * as React$1 from "react";
9
9
  */
10
10
  declare function DocsCommandSearch({
11
11
  api,
12
- locale
12
+ locale,
13
+ analytics
13
14
  }: {
14
15
  api?: string;
15
16
  locale?: string;
17
+ analytics?: boolean;
16
18
  }): React$1.ReactPortal | null;
17
19
  //#endregion
18
20
  export { DocsCommandSearch };