@momentumcms/plugins-analytics 0.4.0 → 0.4.1
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/index.cjs +289 -43
- package/index.js +285 -43
- package/lib/analytics-admin-routes.cjs +74 -9
- package/lib/analytics-admin-routes.js +72 -7
- package/lib/page-view-tracker.cjs +208 -0
- package/lib/page-view-tracker.js +189 -0
- package/package.json +9 -5
- package/src/index.d.ts +3 -1
- package/src/lib/analytics-config.types.d.ts +29 -0
- package/src/lib/analytics-plugin.d.ts +1 -1
- package/src/lib/collectors/page-view-collector.d.ts +29 -0
- package/src/lib/page-view-tracker.d.ts +21 -0
- package/src/lib/page-view-tracker.utils.d.ts +28 -0
- package/src/lib/utils/content-route-matcher.d.ts +42 -0
|
@@ -296,6 +296,7 @@ import {
|
|
|
296
296
|
PLATFORM_ID as PLATFORM_ID2
|
|
297
297
|
} from "@angular/core";
|
|
298
298
|
import { isPlatformBrowser as isPlatformBrowser2 } from "@angular/common";
|
|
299
|
+
import { Router, ActivatedRoute } from "@angular/router";
|
|
299
300
|
import {
|
|
300
301
|
Card as Card2,
|
|
301
302
|
CardHeader as CardHeader2,
|
|
@@ -329,6 +330,8 @@ var init_analytics_dashboard_page = __esm({
|
|
|
329
330
|
constructor() {
|
|
330
331
|
this.analytics = inject2(AnalyticsService);
|
|
331
332
|
this.platformId = inject2(PLATFORM_ID2);
|
|
333
|
+
this.router = inject2(Router);
|
|
334
|
+
this.route = inject2(ActivatedRoute);
|
|
332
335
|
/** Date range options */
|
|
333
336
|
this.dateRanges = [
|
|
334
337
|
{
|
|
@@ -366,15 +369,12 @@ var init_analytics_dashboard_page = __esm({
|
|
|
366
369
|
this.currentPage = signal3(1);
|
|
367
370
|
/** Events per page */
|
|
368
371
|
this.pageSize = 20;
|
|
369
|
-
/**
|
|
372
|
+
/** Events from server query (filtered server-side by category when selected) */
|
|
370
373
|
this.filteredEvents = computed2(() => {
|
|
371
374
|
const result = this.analytics.events();
|
|
372
375
|
if (!result)
|
|
373
376
|
return [];
|
|
374
|
-
|
|
375
|
-
if (cat === "all")
|
|
376
|
-
return result.events;
|
|
377
|
-
return result.events.filter((e) => e.category === cat);
|
|
377
|
+
return result.events;
|
|
378
378
|
});
|
|
379
379
|
/** Total pages for pagination */
|
|
380
380
|
this.totalPages = computed2(() => {
|
|
@@ -394,6 +394,22 @@ var init_analytics_dashboard_page = __esm({
|
|
|
394
394
|
ngOnInit() {
|
|
395
395
|
if (!isPlatformBrowser2(this.platformId))
|
|
396
396
|
return;
|
|
397
|
+
const params = this.route.snapshot.queryParams;
|
|
398
|
+
if (params["range"] && this.dateRanges.some((r) => r.value === params["range"])) {
|
|
399
|
+
this.selectedRange.set(params["range"]);
|
|
400
|
+
}
|
|
401
|
+
if (params["category"] && this.categoryFilters.some((c) => c.value === params["category"])) {
|
|
402
|
+
this.selectedCategory.set(params["category"]);
|
|
403
|
+
}
|
|
404
|
+
if (params["search"]) {
|
|
405
|
+
this.searchTerm.set(params["search"]);
|
|
406
|
+
}
|
|
407
|
+
if (params["page"]) {
|
|
408
|
+
const page = parseInt(params["page"], 10);
|
|
409
|
+
if (!isNaN(page) && page > 0) {
|
|
410
|
+
this.currentPage.set(page);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
397
413
|
void this.refresh();
|
|
398
414
|
}
|
|
399
415
|
/**
|
|
@@ -404,13 +420,15 @@ var init_analytics_dashboard_page = __esm({
|
|
|
404
420
|
const from = dateRange?.getFrom();
|
|
405
421
|
const search = this.searchTerm() || void 0;
|
|
406
422
|
const page = this.currentPage();
|
|
423
|
+
const category = this.selectedCategory();
|
|
407
424
|
await Promise.all([
|
|
408
425
|
this.analytics.fetchSummary({ from }),
|
|
409
426
|
this.analytics.queryEvents({
|
|
410
427
|
limit: this.pageSize,
|
|
411
428
|
page,
|
|
412
429
|
from,
|
|
413
|
-
search
|
|
430
|
+
search,
|
|
431
|
+
category: category !== "all" ? category : void 0
|
|
414
432
|
})
|
|
415
433
|
]);
|
|
416
434
|
}
|
|
@@ -420,13 +438,17 @@ var init_analytics_dashboard_page = __esm({
|
|
|
420
438
|
setDateRange(range) {
|
|
421
439
|
this.selectedRange.set(range.value);
|
|
422
440
|
this.currentPage.set(1);
|
|
441
|
+
this.syncUrlParams();
|
|
423
442
|
void this.refresh();
|
|
424
443
|
}
|
|
425
444
|
/**
|
|
426
|
-
* Set category filter.
|
|
445
|
+
* Set category filter and re-query server.
|
|
427
446
|
*/
|
|
428
447
|
setCategory(category) {
|
|
429
448
|
this.selectedCategory.set(category);
|
|
449
|
+
this.currentPage.set(1);
|
|
450
|
+
this.syncUrlParams();
|
|
451
|
+
void this.refresh();
|
|
430
452
|
}
|
|
431
453
|
/**
|
|
432
454
|
* Handle search input.
|
|
@@ -436,6 +458,7 @@ var init_analytics_dashboard_page = __esm({
|
|
|
436
458
|
if (target instanceof HTMLInputElement) {
|
|
437
459
|
this.searchTerm.set(target.value);
|
|
438
460
|
this.currentPage.set(1);
|
|
461
|
+
this.syncUrlParams();
|
|
439
462
|
void this.refresh();
|
|
440
463
|
}
|
|
441
464
|
}
|
|
@@ -444,8 +467,26 @@ var init_analytics_dashboard_page = __esm({
|
|
|
444
467
|
*/
|
|
445
468
|
goToPage(page) {
|
|
446
469
|
this.currentPage.set(page);
|
|
470
|
+
this.syncUrlParams();
|
|
447
471
|
void this.refresh();
|
|
448
472
|
}
|
|
473
|
+
/**
|
|
474
|
+
* Sync current filter state to URL query params.
|
|
475
|
+
*/
|
|
476
|
+
syncUrlParams() {
|
|
477
|
+
const queryParams = {
|
|
478
|
+
range: this.selectedRange() !== "all" ? this.selectedRange() : null,
|
|
479
|
+
category: this.selectedCategory() !== "all" ? this.selectedCategory() : null,
|
|
480
|
+
search: this.searchTerm() || null,
|
|
481
|
+
page: this.currentPage() > 1 ? String(this.currentPage()) : null
|
|
482
|
+
};
|
|
483
|
+
void this.router.navigate([], {
|
|
484
|
+
relativeTo: this.route,
|
|
485
|
+
queryParams,
|
|
486
|
+
queryParamsHandling: "merge",
|
|
487
|
+
replaceUrl: true
|
|
488
|
+
});
|
|
489
|
+
}
|
|
449
490
|
/**
|
|
450
491
|
* Get total content operations count.
|
|
451
492
|
*/
|
|
@@ -1152,6 +1193,7 @@ import {
|
|
|
1152
1193
|
PLATFORM_ID as PLATFORM_ID3
|
|
1153
1194
|
} from "@angular/core";
|
|
1154
1195
|
import { isPlatformBrowser as isPlatformBrowser3 } from "@angular/common";
|
|
1196
|
+
import { Router as Router2, ActivatedRoute as ActivatedRoute2 } from "@angular/router";
|
|
1155
1197
|
import {
|
|
1156
1198
|
Card as Card3,
|
|
1157
1199
|
CardHeader as CardHeader3,
|
|
@@ -1180,6 +1222,8 @@ var init_content_performance_page = __esm({
|
|
|
1180
1222
|
constructor() {
|
|
1181
1223
|
this.service = inject3(ContentPerformanceService);
|
|
1182
1224
|
this.platformId = inject3(PLATFORM_ID3);
|
|
1225
|
+
this.router = inject3(Router2);
|
|
1226
|
+
this.route = inject3(ActivatedRoute2);
|
|
1183
1227
|
this.dateRanges = [
|
|
1184
1228
|
{
|
|
1185
1229
|
label: "24h",
|
|
@@ -1229,16 +1273,25 @@ var init_content_performance_page = __esm({
|
|
|
1229
1273
|
ngOnInit() {
|
|
1230
1274
|
if (!isPlatformBrowser3(this.platformId))
|
|
1231
1275
|
return;
|
|
1276
|
+
const params = this.route.snapshot.queryParams;
|
|
1277
|
+
if (params["range"] && this.dateRanges.some((r) => r.value === params["range"])) {
|
|
1278
|
+
this.selectedRange.set(params["range"]);
|
|
1279
|
+
}
|
|
1280
|
+
if (params["search"]) {
|
|
1281
|
+
this.searchTerm.set(params["search"]);
|
|
1282
|
+
}
|
|
1232
1283
|
void this.fetchData();
|
|
1233
1284
|
}
|
|
1234
1285
|
setDateRange(range) {
|
|
1235
1286
|
this.selectedRange.set(range.value);
|
|
1236
1287
|
this.expandedRow.set(null);
|
|
1288
|
+
this.syncUrlParams();
|
|
1237
1289
|
void this.fetchData();
|
|
1238
1290
|
}
|
|
1239
1291
|
onSearch(event) {
|
|
1240
1292
|
if (event.target instanceof HTMLInputElement) {
|
|
1241
1293
|
this.searchTerm.set(event.target.value);
|
|
1294
|
+
this.syncUrlParams();
|
|
1242
1295
|
}
|
|
1243
1296
|
}
|
|
1244
1297
|
toggleRow(url) {
|
|
@@ -1249,6 +1302,18 @@ var init_content_performance_page = __esm({
|
|
|
1249
1302
|
const from = range?.getFrom();
|
|
1250
1303
|
await this.service.fetchTopPages({ from });
|
|
1251
1304
|
}
|
|
1305
|
+
syncUrlParams() {
|
|
1306
|
+
const queryParams = {
|
|
1307
|
+
range: this.selectedRange() !== "all" ? this.selectedRange() : null,
|
|
1308
|
+
search: this.searchTerm() || null
|
|
1309
|
+
};
|
|
1310
|
+
void this.router.navigate([], {
|
|
1311
|
+
relativeTo: this.route,
|
|
1312
|
+
queryParams,
|
|
1313
|
+
queryParamsHandling: "merge",
|
|
1314
|
+
replaceUrl: true
|
|
1315
|
+
});
|
|
1316
|
+
}
|
|
1252
1317
|
};
|
|
1253
1318
|
ContentPerformancePage = __decorateClass([
|
|
1254
1319
|
Component3({
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
var __decorateClass = (decorators, target, key, kind) => {
|
|
20
|
+
var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;
|
|
21
|
+
for (var i = decorators.length - 1, decorator; i >= 0; i--)
|
|
22
|
+
if (decorator = decorators[i])
|
|
23
|
+
result = (kind ? decorator(target, key, result) : decorator(result)) || result;
|
|
24
|
+
if (kind && result)
|
|
25
|
+
__defProp(target, key, result);
|
|
26
|
+
return result;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// libs/plugins/analytics/src/lib/page-view-tracker.ts
|
|
30
|
+
var page_view_tracker_exports = {};
|
|
31
|
+
__export(page_view_tracker_exports, {
|
|
32
|
+
DEFAULT_EXCLUDE_PREFIXES: () => DEFAULT_EXCLUDE_PREFIXES,
|
|
33
|
+
PAGE_VIEW_TRACKING_CONFIG: () => PAGE_VIEW_TRACKING_CONFIG,
|
|
34
|
+
PageViewTrackerService: () => PageViewTrackerService,
|
|
35
|
+
buildPageViewEvent: () => buildPageViewEvent,
|
|
36
|
+
providePageViewTracking: () => providePageViewTracking,
|
|
37
|
+
shouldTrackNavigation: () => shouldTrackNavigation
|
|
38
|
+
});
|
|
39
|
+
module.exports = __toCommonJS(page_view_tracker_exports);
|
|
40
|
+
var import_core = require("@angular/core");
|
|
41
|
+
var import_common = require("@angular/common");
|
|
42
|
+
var import_router = require("@angular/router");
|
|
43
|
+
var import_http = require("@angular/common/http");
|
|
44
|
+
var import_rxjs = require("rxjs");
|
|
45
|
+
|
|
46
|
+
// libs/plugins/analytics/src/lib/utils/content-route-matcher.ts
|
|
47
|
+
function escapeRegex(str) {
|
|
48
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
49
|
+
}
|
|
50
|
+
function compileContentRoute(collection, pattern) {
|
|
51
|
+
const segments = pattern.split("/").filter(Boolean);
|
|
52
|
+
const paramNames = [];
|
|
53
|
+
let staticCount = 0;
|
|
54
|
+
const regexParts = segments.map((seg) => {
|
|
55
|
+
if (seg.startsWith(":")) {
|
|
56
|
+
paramNames.push(seg.slice(1));
|
|
57
|
+
return "([^/]+)";
|
|
58
|
+
}
|
|
59
|
+
staticCount++;
|
|
60
|
+
return escapeRegex(seg);
|
|
61
|
+
});
|
|
62
|
+
const regexStr = "^/" + regexParts.join("/") + "/?$";
|
|
63
|
+
return {
|
|
64
|
+
collection,
|
|
65
|
+
pattern,
|
|
66
|
+
regex: new RegExp(regexStr),
|
|
67
|
+
paramNames,
|
|
68
|
+
staticSegments: staticCount
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
function compileContentRoutes(routes) {
|
|
72
|
+
const compiled = Object.entries(routes).map(
|
|
73
|
+
([collection, pattern]) => compileContentRoute(collection, pattern)
|
|
74
|
+
);
|
|
75
|
+
compiled.sort((a, b) => b.staticSegments - a.staticSegments);
|
|
76
|
+
return compiled;
|
|
77
|
+
}
|
|
78
|
+
function matchContentRoute(path, routes) {
|
|
79
|
+
for (const route of routes) {
|
|
80
|
+
const match = route.regex.exec(path);
|
|
81
|
+
if (match) {
|
|
82
|
+
const params = {};
|
|
83
|
+
for (let i = 0; i < route.paramNames.length; i++) {
|
|
84
|
+
params[route.paramNames[i]] = match[i + 1];
|
|
85
|
+
}
|
|
86
|
+
return { collection: route.collection, params };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return void 0;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// libs/plugins/analytics/src/lib/page-view-tracker.utils.ts
|
|
93
|
+
var DEFAULT_EXCLUDE_PREFIXES = ["/admin", "/api/"];
|
|
94
|
+
function shouldTrackNavigation(url, isFirstNavigation, excludePrefixes) {
|
|
95
|
+
if (isFirstNavigation)
|
|
96
|
+
return false;
|
|
97
|
+
const path = url.split("?")[0].split("#")[0];
|
|
98
|
+
for (const prefix of excludePrefixes) {
|
|
99
|
+
if (path.startsWith(prefix))
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
function buildPageViewEvent(url, compiledRoutes) {
|
|
105
|
+
const path = url.split("?")[0].split("#")[0];
|
|
106
|
+
const properties = { path };
|
|
107
|
+
if (compiledRoutes) {
|
|
108
|
+
const routeMatch = matchContentRoute(path, compiledRoutes);
|
|
109
|
+
if (routeMatch) {
|
|
110
|
+
properties["collection"] = routeMatch.collection;
|
|
111
|
+
properties["slug"] = routeMatch.params["slug"];
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
name: "page_view",
|
|
116
|
+
category: "page",
|
|
117
|
+
properties
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// libs/plugins/analytics/src/lib/page-view-tracker.ts
|
|
122
|
+
var PAGE_VIEW_TRACKING_CONFIG = new import_core.InjectionToken(
|
|
123
|
+
"PAGE_VIEW_TRACKING_CONFIG"
|
|
124
|
+
);
|
|
125
|
+
function getOrCreateStorageId(storage, key) {
|
|
126
|
+
let id = storage.getItem(key);
|
|
127
|
+
if (!id) {
|
|
128
|
+
id = Date.now().toString(36) + Math.random().toString(36).slice(2, 10);
|
|
129
|
+
storage.setItem(key, id);
|
|
130
|
+
}
|
|
131
|
+
return id;
|
|
132
|
+
}
|
|
133
|
+
var PageViewTrackerService = class {
|
|
134
|
+
constructor() {
|
|
135
|
+
this.router = (0, import_core.inject)(import_router.Router);
|
|
136
|
+
this.http = (0, import_core.inject)(import_http.HttpClient);
|
|
137
|
+
this.platformId = (0, import_core.inject)(import_core.PLATFORM_ID);
|
|
138
|
+
this.destroyRef = (0, import_core.inject)(import_core.DestroyRef);
|
|
139
|
+
this.config = (0, import_core.inject)(PAGE_VIEW_TRACKING_CONFIG);
|
|
140
|
+
this.doc = (0, import_core.inject)(import_common.DOCUMENT);
|
|
141
|
+
this.isFirstNavigation = true;
|
|
142
|
+
this.endpoint = this.config.endpoint ?? "/api/analytics/collect";
|
|
143
|
+
this.compiledRoutes = this.config.contentRoutes ? compileContentRoutes(this.config.contentRoutes) : void 0;
|
|
144
|
+
this.excludePrefixes = this.config.excludePrefixes ? [...DEFAULT_EXCLUDE_PREFIXES, ...this.config.excludePrefixes] : DEFAULT_EXCLUDE_PREFIXES;
|
|
145
|
+
if (!(0, import_common.isPlatformBrowser)(this.platformId))
|
|
146
|
+
return;
|
|
147
|
+
const sub = this.router.events.pipe((0, import_rxjs.filter)((e) => e instanceof import_router.NavigationEnd)).subscribe((event) => {
|
|
148
|
+
const url = event.urlAfterRedirects;
|
|
149
|
+
if (!shouldTrackNavigation(url, this.isFirstNavigation, this.excludePrefixes)) {
|
|
150
|
+
this.isFirstNavigation = false;
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
this.isFirstNavigation = false;
|
|
154
|
+
this.trackPageView(url);
|
|
155
|
+
});
|
|
156
|
+
this.destroyRef.onDestroy(() => sub.unsubscribe());
|
|
157
|
+
}
|
|
158
|
+
trackPageView(url) {
|
|
159
|
+
const eventPayload = buildPageViewEvent(url, this.compiledRoutes);
|
|
160
|
+
const win = this.doc.defaultView;
|
|
161
|
+
let visitorId;
|
|
162
|
+
let sessionId;
|
|
163
|
+
try {
|
|
164
|
+
if (win?.localStorage)
|
|
165
|
+
visitorId = getOrCreateStorageId(win.localStorage, "_m_vid");
|
|
166
|
+
if (win?.sessionStorage)
|
|
167
|
+
sessionId = getOrCreateStorageId(win.sessionStorage, "_m_sid");
|
|
168
|
+
} catch {
|
|
169
|
+
}
|
|
170
|
+
const clientEvent = {
|
|
171
|
+
...eventPayload,
|
|
172
|
+
context: {
|
|
173
|
+
url: win?.location?.href ?? "",
|
|
174
|
+
referrer: this.doc.referrer
|
|
175
|
+
},
|
|
176
|
+
visitorId,
|
|
177
|
+
sessionId
|
|
178
|
+
};
|
|
179
|
+
this.http.post(this.endpoint, { events: [clientEvent] }).subscribe();
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
PageViewTrackerService = __decorateClass([
|
|
183
|
+
(0, import_core.Injectable)()
|
|
184
|
+
], PageViewTrackerService);
|
|
185
|
+
function providePageViewTracking(config) {
|
|
186
|
+
return (0, import_core.makeEnvironmentProviders)([
|
|
187
|
+
{ provide: PAGE_VIEW_TRACKING_CONFIG, useValue: config },
|
|
188
|
+
PageViewTrackerService,
|
|
189
|
+
{
|
|
190
|
+
provide: import_core.ENVIRONMENT_INITIALIZER,
|
|
191
|
+
multi: true,
|
|
192
|
+
useFactory: () => {
|
|
193
|
+
(0, import_core.inject)(PageViewTrackerService);
|
|
194
|
+
return () => {
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
]);
|
|
199
|
+
}
|
|
200
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
201
|
+
0 && (module.exports = {
|
|
202
|
+
DEFAULT_EXCLUDE_PREFIXES,
|
|
203
|
+
PAGE_VIEW_TRACKING_CONFIG,
|
|
204
|
+
PageViewTrackerService,
|
|
205
|
+
buildPageViewEvent,
|
|
206
|
+
providePageViewTracking,
|
|
207
|
+
shouldTrackNavigation
|
|
208
|
+
});
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
var __defProp = Object.defineProperty;
|
|
2
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
3
|
+
var __decorateClass = (decorators, target, key, kind) => {
|
|
4
|
+
var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;
|
|
5
|
+
for (var i = decorators.length - 1, decorator; i >= 0; i--)
|
|
6
|
+
if (decorator = decorators[i])
|
|
7
|
+
result = (kind ? decorator(target, key, result) : decorator(result)) || result;
|
|
8
|
+
if (kind && result)
|
|
9
|
+
__defProp(target, key, result);
|
|
10
|
+
return result;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
// libs/plugins/analytics/src/lib/page-view-tracker.ts
|
|
14
|
+
import {
|
|
15
|
+
Injectable,
|
|
16
|
+
InjectionToken,
|
|
17
|
+
inject,
|
|
18
|
+
DestroyRef,
|
|
19
|
+
PLATFORM_ID,
|
|
20
|
+
makeEnvironmentProviders,
|
|
21
|
+
ENVIRONMENT_INITIALIZER
|
|
22
|
+
} from "@angular/core";
|
|
23
|
+
import { DOCUMENT, isPlatformBrowser } from "@angular/common";
|
|
24
|
+
import { Router, NavigationEnd } from "@angular/router";
|
|
25
|
+
import { HttpClient } from "@angular/common/http";
|
|
26
|
+
import { filter } from "rxjs";
|
|
27
|
+
|
|
28
|
+
// libs/plugins/analytics/src/lib/utils/content-route-matcher.ts
|
|
29
|
+
function escapeRegex(str) {
|
|
30
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
31
|
+
}
|
|
32
|
+
function compileContentRoute(collection, pattern) {
|
|
33
|
+
const segments = pattern.split("/").filter(Boolean);
|
|
34
|
+
const paramNames = [];
|
|
35
|
+
let staticCount = 0;
|
|
36
|
+
const regexParts = segments.map((seg) => {
|
|
37
|
+
if (seg.startsWith(":")) {
|
|
38
|
+
paramNames.push(seg.slice(1));
|
|
39
|
+
return "([^/]+)";
|
|
40
|
+
}
|
|
41
|
+
staticCount++;
|
|
42
|
+
return escapeRegex(seg);
|
|
43
|
+
});
|
|
44
|
+
const regexStr = "^/" + regexParts.join("/") + "/?$";
|
|
45
|
+
return {
|
|
46
|
+
collection,
|
|
47
|
+
pattern,
|
|
48
|
+
regex: new RegExp(regexStr),
|
|
49
|
+
paramNames,
|
|
50
|
+
staticSegments: staticCount
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
function compileContentRoutes(routes) {
|
|
54
|
+
const compiled = Object.entries(routes).map(
|
|
55
|
+
([collection, pattern]) => compileContentRoute(collection, pattern)
|
|
56
|
+
);
|
|
57
|
+
compiled.sort((a, b) => b.staticSegments - a.staticSegments);
|
|
58
|
+
return compiled;
|
|
59
|
+
}
|
|
60
|
+
function matchContentRoute(path, routes) {
|
|
61
|
+
for (const route of routes) {
|
|
62
|
+
const match = route.regex.exec(path);
|
|
63
|
+
if (match) {
|
|
64
|
+
const params = {};
|
|
65
|
+
for (let i = 0; i < route.paramNames.length; i++) {
|
|
66
|
+
params[route.paramNames[i]] = match[i + 1];
|
|
67
|
+
}
|
|
68
|
+
return { collection: route.collection, params };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return void 0;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// libs/plugins/analytics/src/lib/page-view-tracker.utils.ts
|
|
75
|
+
var DEFAULT_EXCLUDE_PREFIXES = ["/admin", "/api/"];
|
|
76
|
+
function shouldTrackNavigation(url, isFirstNavigation, excludePrefixes) {
|
|
77
|
+
if (isFirstNavigation)
|
|
78
|
+
return false;
|
|
79
|
+
const path = url.split("?")[0].split("#")[0];
|
|
80
|
+
for (const prefix of excludePrefixes) {
|
|
81
|
+
if (path.startsWith(prefix))
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
function buildPageViewEvent(url, compiledRoutes) {
|
|
87
|
+
const path = url.split("?")[0].split("#")[0];
|
|
88
|
+
const properties = { path };
|
|
89
|
+
if (compiledRoutes) {
|
|
90
|
+
const routeMatch = matchContentRoute(path, compiledRoutes);
|
|
91
|
+
if (routeMatch) {
|
|
92
|
+
properties["collection"] = routeMatch.collection;
|
|
93
|
+
properties["slug"] = routeMatch.params["slug"];
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
name: "page_view",
|
|
98
|
+
category: "page",
|
|
99
|
+
properties
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// libs/plugins/analytics/src/lib/page-view-tracker.ts
|
|
104
|
+
var PAGE_VIEW_TRACKING_CONFIG = new InjectionToken(
|
|
105
|
+
"PAGE_VIEW_TRACKING_CONFIG"
|
|
106
|
+
);
|
|
107
|
+
function getOrCreateStorageId(storage, key) {
|
|
108
|
+
let id = storage.getItem(key);
|
|
109
|
+
if (!id) {
|
|
110
|
+
id = Date.now().toString(36) + Math.random().toString(36).slice(2, 10);
|
|
111
|
+
storage.setItem(key, id);
|
|
112
|
+
}
|
|
113
|
+
return id;
|
|
114
|
+
}
|
|
115
|
+
var PageViewTrackerService = class {
|
|
116
|
+
constructor() {
|
|
117
|
+
this.router = inject(Router);
|
|
118
|
+
this.http = inject(HttpClient);
|
|
119
|
+
this.platformId = inject(PLATFORM_ID);
|
|
120
|
+
this.destroyRef = inject(DestroyRef);
|
|
121
|
+
this.config = inject(PAGE_VIEW_TRACKING_CONFIG);
|
|
122
|
+
this.doc = inject(DOCUMENT);
|
|
123
|
+
this.isFirstNavigation = true;
|
|
124
|
+
this.endpoint = this.config.endpoint ?? "/api/analytics/collect";
|
|
125
|
+
this.compiledRoutes = this.config.contentRoutes ? compileContentRoutes(this.config.contentRoutes) : void 0;
|
|
126
|
+
this.excludePrefixes = this.config.excludePrefixes ? [...DEFAULT_EXCLUDE_PREFIXES, ...this.config.excludePrefixes] : DEFAULT_EXCLUDE_PREFIXES;
|
|
127
|
+
if (!isPlatformBrowser(this.platformId))
|
|
128
|
+
return;
|
|
129
|
+
const sub = this.router.events.pipe(filter((e) => e instanceof NavigationEnd)).subscribe((event) => {
|
|
130
|
+
const url = event.urlAfterRedirects;
|
|
131
|
+
if (!shouldTrackNavigation(url, this.isFirstNavigation, this.excludePrefixes)) {
|
|
132
|
+
this.isFirstNavigation = false;
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
this.isFirstNavigation = false;
|
|
136
|
+
this.trackPageView(url);
|
|
137
|
+
});
|
|
138
|
+
this.destroyRef.onDestroy(() => sub.unsubscribe());
|
|
139
|
+
}
|
|
140
|
+
trackPageView(url) {
|
|
141
|
+
const eventPayload = buildPageViewEvent(url, this.compiledRoutes);
|
|
142
|
+
const win = this.doc.defaultView;
|
|
143
|
+
let visitorId;
|
|
144
|
+
let sessionId;
|
|
145
|
+
try {
|
|
146
|
+
if (win?.localStorage)
|
|
147
|
+
visitorId = getOrCreateStorageId(win.localStorage, "_m_vid");
|
|
148
|
+
if (win?.sessionStorage)
|
|
149
|
+
sessionId = getOrCreateStorageId(win.sessionStorage, "_m_sid");
|
|
150
|
+
} catch {
|
|
151
|
+
}
|
|
152
|
+
const clientEvent = {
|
|
153
|
+
...eventPayload,
|
|
154
|
+
context: {
|
|
155
|
+
url: win?.location?.href ?? "",
|
|
156
|
+
referrer: this.doc.referrer
|
|
157
|
+
},
|
|
158
|
+
visitorId,
|
|
159
|
+
sessionId
|
|
160
|
+
};
|
|
161
|
+
this.http.post(this.endpoint, { events: [clientEvent] }).subscribe();
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
PageViewTrackerService = __decorateClass([
|
|
165
|
+
Injectable()
|
|
166
|
+
], PageViewTrackerService);
|
|
167
|
+
function providePageViewTracking(config) {
|
|
168
|
+
return makeEnvironmentProviders([
|
|
169
|
+
{ provide: PAGE_VIEW_TRACKING_CONFIG, useValue: config },
|
|
170
|
+
PageViewTrackerService,
|
|
171
|
+
{
|
|
172
|
+
provide: ENVIRONMENT_INITIALIZER,
|
|
173
|
+
multi: true,
|
|
174
|
+
useFactory: () => {
|
|
175
|
+
inject(PageViewTrackerService);
|
|
176
|
+
return () => {
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
]);
|
|
181
|
+
}
|
|
182
|
+
export {
|
|
183
|
+
DEFAULT_EXCLUDE_PREFIXES,
|
|
184
|
+
PAGE_VIEW_TRACKING_CONFIG,
|
|
185
|
+
PageViewTrackerService,
|
|
186
|
+
buildPageViewEvent,
|
|
187
|
+
providePageViewTracking,
|
|
188
|
+
shouldTrackNavigation
|
|
189
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@momentumcms/plugins-analytics",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Analytics and content tracking plugin for Momentum CMS",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Momentum CMS Contributors",
|
|
@@ -41,13 +41,17 @@
|
|
|
41
41
|
"./client": {
|
|
42
42
|
"types": "./src/lib/client/tracker.d.ts",
|
|
43
43
|
"default": "./lib/client/tracker.cjs"
|
|
44
|
+
},
|
|
45
|
+
"./page-tracker": {
|
|
46
|
+
"types": "./src/lib/page-view-tracker.d.ts",
|
|
47
|
+
"default": "./lib/page-view-tracker.cjs"
|
|
44
48
|
}
|
|
45
49
|
},
|
|
46
50
|
"dependencies": {
|
|
47
|
-
"@momentumcms/core": "0.4.
|
|
48
|
-
"@momentumcms/logger": "0.4.
|
|
49
|
-
"@momentumcms/plugins-core": "0.4.
|
|
50
|
-
"@momentumcms/server-core": "0.4.
|
|
51
|
+
"@momentumcms/core": "0.4.1",
|
|
52
|
+
"@momentumcms/logger": "0.4.1",
|
|
53
|
+
"@momentumcms/plugins-core": "0.4.1",
|
|
54
|
+
"@momentumcms/server-core": "0.4.1",
|
|
51
55
|
"express": "^4.21.0"
|
|
52
56
|
},
|
|
53
57
|
"peerDependencies": {
|
package/src/index.d.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
export type { AnalyticsEvent, AnalyticsCategory, AnalyticsContext, AnalyticsQueryOptions, AnalyticsQueryResult, } from './lib/analytics-event.types';
|
|
2
|
-
export type { AnalyticsAdapter, AnalyticsConfig } from './lib/analytics-config.types';
|
|
2
|
+
export type { AnalyticsAdapter, AnalyticsConfig, PageViewTrackingOptions, } from './lib/analytics-config.types';
|
|
3
3
|
export { EventStore, type EventStoreOptions } from './lib/event-store';
|
|
4
4
|
export { injectCollectionCollector, type AnalyticsEmitter, type CollectionCollectorOptions, } from './lib/collectors/collection-collector';
|
|
5
5
|
export { createApiCollectorMiddleware } from './lib/collectors/api-collector';
|
|
6
|
+
export { createPageViewCollectorMiddleware, isBot, type PageViewEmitter, } from './lib/collectors/page-view-collector';
|
|
7
|
+
export { compileContentRoutes, matchContentRoute, type CompiledContentRoute, type ContentRouteMatch, } from './lib/utils/content-route-matcher';
|
|
6
8
|
export { createIngestRouter, type IngestHandlerOptions } from './lib/ingest-handler';
|
|
7
9
|
export { MemoryAnalyticsAdapter } from './lib/adapters/memory-adapter';
|
|
8
10
|
export { postgresAnalyticsAdapter, type PostgresAnalyticsAdapterOptions, } from './lib/adapters/postgres-adapter';
|
|
@@ -16,6 +16,27 @@ export interface AnalyticsAdapter {
|
|
|
16
16
|
/** Flush pending events and close connections */
|
|
17
17
|
shutdown?(): Promise<void>;
|
|
18
18
|
}
|
|
19
|
+
/**
|
|
20
|
+
* Options for server-side page view tracking.
|
|
21
|
+
*/
|
|
22
|
+
export interface PageViewTrackingOptions {
|
|
23
|
+
/** Additional path prefixes to exclude from tracking. */
|
|
24
|
+
excludePaths?: string[];
|
|
25
|
+
/** File extensions to ignore (overrides defaults when provided). */
|
|
26
|
+
excludeExtensions?: string[];
|
|
27
|
+
/** Only track responses with 2xx status codes. @default true */
|
|
28
|
+
onlySuccessful?: boolean;
|
|
29
|
+
/** Whether to track bot traffic (Googlebot, Bingbot, etc.). @default false */
|
|
30
|
+
trackBots?: boolean;
|
|
31
|
+
/**
|
|
32
|
+
* Map collection slugs to URL patterns for content attribution.
|
|
33
|
+
* When a page view URL matches a pattern, the event is enriched with
|
|
34
|
+
* collection and slug metadata, enabling per-document analytics.
|
|
35
|
+
* Patterns use Express-style `:param` syntax.
|
|
36
|
+
* @example { articles: '/articles/:slug', pages: '/:slug' }
|
|
37
|
+
*/
|
|
38
|
+
contentRoutes?: Record<string, string>;
|
|
39
|
+
}
|
|
19
40
|
/**
|
|
20
41
|
* Analytics configuration.
|
|
21
42
|
*/
|
|
@@ -30,6 +51,14 @@ export interface AnalyticsConfig {
|
|
|
30
51
|
trackApi?: boolean;
|
|
31
52
|
/** Admin action tracking. @default true */
|
|
32
53
|
trackAdmin?: boolean;
|
|
54
|
+
/**
|
|
55
|
+
* Server-side page view tracking (SSR page renders).
|
|
56
|
+
* - `true` (default): enable with default settings
|
|
57
|
+
* - `false`: disable
|
|
58
|
+
* - object: override page view tracking options
|
|
59
|
+
* @default true
|
|
60
|
+
*/
|
|
61
|
+
trackPageViews?: boolean | PageViewTrackingOptions;
|
|
33
62
|
/** Client-side ingest endpoint path. @default '/api/analytics/collect' */
|
|
34
63
|
ingestPath?: string;
|
|
35
64
|
/** Rate limit for ingest endpoint (requests per minute per IP). @default 100 */
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Analytics Plugin
|
|
3
3
|
*
|
|
4
4
|
* A Momentum CMS plugin that wires all analytics collectors together.
|
|
5
|
-
* Tracks collection CRUD, API requests, and accepts client-side events.
|
|
5
|
+
* Tracks collection CRUD, API requests, page views, and accepts client-side events.
|
|
6
6
|
*
|
|
7
7
|
* @example
|
|
8
8
|
* ```typescript
|