@eox/pages-theme-eox 2.0.0-alpha.5 → 2.0.0-alpha.7

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/.gitlab-ci.yml CHANGED
@@ -10,9 +10,11 @@ cache:
10
10
  - package-lock.json
11
11
  paths:
12
12
  - .npm/
13
+ - .cache/Cypress
13
14
 
14
15
  variables:
15
16
  npm_config_cache: "$CI_PROJECT_DIR/.npm"
17
+ CYPRESS_CACHE_FOLDER: "$CI_PROJECT_DIR/.cache/Cypress"
16
18
 
17
19
  security:
18
20
  stage: security
@@ -33,6 +35,7 @@ test:
33
35
  stage: test
34
36
  script:
35
37
  - npm ci
36
- - npm run test:component
38
+ - npx cypress install
39
+ - npm run test
37
40
  rules:
38
41
  - if: $CI_PIPELINE_SOURCE == 'merge_request_event' && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == 'main'
package/README.md CHANGED
@@ -21,6 +21,45 @@ See also the [Vitepress Docs](https://vitepress.dev/guide/custom-theme#consuming
21
21
 
22
22
  This theme supports standard [VitePress Default Theme Configuration](https://vitepress.dev/reference/default-theme-config) with some specific adaptations.
23
23
 
24
+ ### LLMs Configuration (`llms.txt`)
25
+
26
+ This theme integrates [vitepress-plugin-llms](https://github.com/nmoodev/vitepress-plugin-llms) to automatically generate AI-friendly documentation (`llms.txt` and `llms-full.txt`), along with custom support for VitePress story routes and remote story manifests.
27
+
28
+ Generation is enabled by default. You can configure options via `brandConfig.llms` in your brand configuration:
29
+
30
+ ```javascript
31
+ // Example brandConfig:
32
+ export const config = {
33
+ // ... other brand settings ...
34
+ llms: {
35
+ // Upstream vitepress-plugin-llms options
36
+ domain: "https://yourdomain.com", // Base URL to resolve absolute links in llms.txt
37
+ copyButtons: true, // (Optional) Adds copy buttons to code blocks in documentation
38
+
39
+ // Theme-specific Story Routes config
40
+ stories: {
41
+ heading: "Stories", // (Optional) Heading used in llms.txt and route pages (default: "Stories")
42
+ path: "narratives/[narrative]", // (Optional) Story route pattern (e.g. "narratives/[narrative]" or "story"). Also accepts `storyPath`
43
+ manifest: "https://cdn.example.com/narratives.json", // (Optional) URL to stories manifest (auto-detected from gallery components if omitted)
44
+ concurrency: 8, // (Optional) Maximum concurrent story downloads (default: 8)
45
+ },
46
+ },
47
+ };
48
+ ```
49
+
50
+ #### Disabling LLMs
51
+
52
+ - To disable LLM documentation generation entirely: set `llms: false`.
53
+ - To generate general docs without stories: set `stories: false` inside `llms`:
54
+
55
+ ```javascript
56
+ export const config = {
57
+ llms: {
58
+ stories: false,
59
+ },
60
+ };
61
+ ```
62
+
24
63
  ### Navigation Bar
25
64
 
26
65
  The Navigation Bar follows the [Default Theme Nav](https://vitepress.dev/reference/default-theme-nav) structure with the following differences:
@@ -572,7 +611,14 @@ feedback: false
572
611
  The theme exports a set of shared utility and helper functions from its helpers module. You can import and use these helpers in your custom VitePress components, checkout pages, or inline script setups:
573
612
 
574
613
  ```javascript
575
- import { formatPrice, trackEvent, t } from "@eox/pages-theme-eox/src/helpers";
614
+ import {
615
+ formatPrice,
616
+ trackEvent,
617
+ trackPageView,
618
+ trackSiteSearch,
619
+ normalizeTrackingUrl,
620
+ t,
621
+ } from "@eox/pages-theme-eox/src/helpers";
576
622
  ```
577
623
 
578
624
  ### Available Helpers
@@ -598,12 +644,12 @@ formatPrice(12000, "en"); // "€ 12,000.-"
598
644
  formatPrice(49.99, "de"); // "€ 49,99"
599
645
  ```
600
646
 
601
- #### `trackEvent(eventDetails)`
647
+ #### `trackEvent(eventDetails, ...rest)`
602
648
 
603
- Sends a custom tracking event to Matomo Analytics.
649
+ Sends a custom tracking event to Matomo Analytics. Automatically normalizes any path strings passed in category, action, or name.
604
650
 
605
651
  - **Parameters**:
606
- - `eventDetails` (`Array<string>`): An array representing the event payload: `[Category, Action, Name?, Value?]`.
652
+ - `eventDetails` (`Array<string|number>|string`): An array `[Category, Action, Name?, Value?]` or positional arguments `(category, action, name, value)`.
607
653
 
608
654
  ##### Example Usage:
609
655
 
@@ -611,8 +657,51 @@ Sends a custom tracking event to Matomo Analytics.
611
657
  import { trackEvent } from "@eox/pages-theme-eox/src/helpers";
612
658
 
613
659
  trackEvent(["CTA", "Click", "Technical Specifications", "Viewing Ready"]);
660
+ trackEvent("indicators", "select_indicator", "N1_NO2");
661
+ ```
662
+
663
+ #### `trackPageView(options)`
664
+
665
+ Tracks a pageview with normalized path (trailing slash stripped, transient parameters removed) and custom document title.
666
+
667
+ - **Parameters**:
668
+ - `options` (`Object|string`): Vitepress router object, `{ path, title }` options object, or custom path string.
669
+
670
+ ##### Example Usage:
671
+
672
+ ```javascript
673
+ import { trackPageView } from "@eox/pages-theme-eox/src/helpers";
674
+
675
+ trackPageView({ path: "/explore/", title: "Explore" });
676
+ // Sends normalized URL "/explore" to Matomo
677
+ ```
678
+
679
+ #### `trackSiteSearch(keyword, category, resultsCount)`
680
+
681
+ Sends search keyword telemetry to Matomo Site Search reports.
682
+
683
+ - **Parameters**:
684
+ - `keyword` (`string`): Searched term.
685
+ - `category` (`string|boolean`): Category (optional, e.g. `'indicators'`, `'stories'`).
686
+ - `resultsCount` (`number`): Number of search results returned.
687
+
688
+ ##### Example Usage:
689
+
690
+ ```javascript
691
+ import { trackSiteSearch } from "@eox/pages-theme-eox/src/helpers";
692
+
693
+ trackSiteSearch("methane", "indicators", 4);
614
694
  ```
615
695
 
696
+ #### `normalizeTrackingUrl(inputUrl, options)`
697
+
698
+ Pure helper that strips trailing slashes (e.g., `/explore/` -> `/explore`), strips volatile coordinate query params (`x`, `y`, `z`), and sorts remaining query parameters.
699
+
700
+ - **Parameters**:
701
+ - `inputUrl` (`string`): URL or path to normalize.
702
+ - `options` (`Object`): Optional configuration (`ignoredParams`, `stripTrailingSlash`).
703
+ - **Returns**: `string` - Normalized URL string.
704
+
616
705
  #### `t(key, i18n)`
617
706
 
618
707
  Retrieves localized translation strings from an i18n mapping object using dot-notation. Falls back to the key itself if no translation is defined.
@@ -0,0 +1,14 @@
1
+ export const data = [
2
+ {
3
+ id: "mock-author-1",
4
+ name: "Mock Author 1",
5
+ url: "/team/mock-author-1",
6
+ guest: false,
7
+ },
8
+ {
9
+ id: "mock-author-2",
10
+ name: "Mock Author 2",
11
+ url: "/team/mock-author-2",
12
+ guest: false,
13
+ },
14
+ ];
@@ -1,7 +1,40 @@
1
1
  // Mock generic helpers
2
2
  // We delegate to a global object to allow spying in tests
3
3
  window.__helpersMock = {
4
- trackEvent: (details) => {},
4
+ trackEvent: (details, ...rest) => {},
5
+ trackPageView: (options) => {},
6
+ trackSiteSearch: (keyword, category, count) => {},
7
+ normalizeTrackingUrl: (url, options) => {
8
+ if (!url || typeof url !== "string") return url;
9
+ const { ignoredParams = ["x", "y", "z"], stripTrailingSlash = true } =
10
+ options || {};
11
+ try {
12
+ const base =
13
+ typeof window !== "undefined" && window.location?.href
14
+ ? window.location.href
15
+ : "https://eox.at";
16
+ const u = new URL(url, base);
17
+ let pathname = u.pathname;
18
+ if (stripTrailingSlash && pathname.length > 1 && pathname.endsWith("/")) {
19
+ pathname = pathname.replace(/\/+$/, "");
20
+ }
21
+ const searchParams = new URLSearchParams(u.search);
22
+ for (const key of ignoredParams) {
23
+ searchParams.delete(key);
24
+ }
25
+ searchParams.sort();
26
+ const search = searchParams.toString();
27
+ const isRelative = !/^https?:\/\//i.test(url);
28
+ if (isRelative) {
29
+ return pathname + (search ? `?${search}` : "");
30
+ }
31
+ u.pathname = pathname;
32
+ u.search = search ? `?${search}` : "";
33
+ return u.toString();
34
+ } catch {
35
+ return url;
36
+ }
37
+ },
5
38
  acceptCookies: (router) => {},
6
39
  declineCookies: (router) => {},
7
40
  enableTracking: (enable, router) => {},
@@ -94,6 +127,12 @@ window.__helpersMock = {
94
127
  };
95
128
 
96
129
  export const trackEvent = (...args) => window.__helpersMock.trackEvent(...args);
130
+ export const trackPageView = (...args) =>
131
+ window.__helpersMock.trackPageView(...args);
132
+ export const trackSiteSearch = (...args) =>
133
+ window.__helpersMock.trackSiteSearch(...args);
134
+ export const normalizeTrackingUrl = (...args) =>
135
+ window.__helpersMock.normalizeTrackingUrl(...args);
97
136
  export const acceptCookies = (...args) =>
98
137
  window.__helpersMock.acceptCookies(...args);
99
138
  export const declineCookies = (...args) =>
package/cypress.config.js CHANGED
@@ -34,6 +34,15 @@ export default defineConfig({
34
34
  __dirname,
35
35
  "cypress/support/mocks/features.data.js",
36
36
  ),
37
+ // Alias authors.data.js to our mock
38
+ "../authors.data.js": path.resolve(
39
+ __dirname,
40
+ "cypress/support/mocks/authors.data.js",
41
+ ),
42
+ "../../authors.data.js": path.resolve(
43
+ __dirname,
44
+ "cypress/support/mocks/authors.data.js",
45
+ ),
37
46
  },
38
47
  },
39
48
  plugins: [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eox/pages-theme-eox",
3
- "version": "2.0.0-alpha.5",
3
+ "version": "2.0.0-alpha.7",
4
4
  "type": "module",
5
5
  "description": "Vitepress Theme with EOX branding",
6
6
  "main": "src/index.js",
@@ -12,6 +12,8 @@
12
12
  "scripts": {
13
13
  "format": "npx prettier --write .",
14
14
  "format:check": "npx prettier --check .",
15
+ "test": "npm run test:unit && npm run test:component",
16
+ "test:unit": "node --test src/helpers.test.js src/llms/llms.test.js",
15
17
  "test:component": "cypress run --component",
16
18
  "release": "release-it"
17
19
  },
@@ -19,7 +21,8 @@
19
21
  "@eox/eslint-config": "^2.0.0",
20
22
  "@eox/feedback": "^1.1.1",
21
23
  "@eox/ui": "^1.0.1",
22
- "vitepress": "^1.6.3"
24
+ "vitepress": "^1.6.3",
25
+ "vitepress-plugin-llms": "^1.13.5"
23
26
  },
24
27
  "devDependencies": {
25
28
  "@release-it/conventional-changelog": "^10.0.6",
@@ -191,7 +191,19 @@ const coverImage = computed(() => {
191
191
 
192
192
  // Dynamic Recent Posts (excluding current page)
193
193
  const calculatedRecentPosts = computed(() => {
194
- return posts.filter((p) => p.url !== page.value.url).slice(0, 5);
194
+ const rel = page.value.relativePath || "";
195
+ const current = (
196
+ "/" + rel.replace(/(^|\/)index\.md$/, "$1").replace(/\.md$/, "")
197
+ )
198
+ .replace(/\/$/, "")
199
+ .replace(/\.html$/, "");
200
+
201
+ return posts
202
+ .filter((p) => {
203
+ const pUrl = (p.url || "").replace(/\/$/, "").replace(/\.html$/, "");
204
+ return pUrl !== current && p.title !== frontmatter.value.title;
205
+ })
206
+ .slice(0, 5);
195
207
  });
196
208
 
197
209
  // Dynamic Hot Tags
@@ -1,3 +1,4 @@
1
+ import { h } from "vue";
1
2
  import FeatureSection from "./FeatureSection.vue";
2
3
 
3
4
  describe("<FeatureSection />", () => {
@@ -60,4 +61,19 @@ describe("<FeatureSection />", () => {
60
61
  cy.get(".feature-section").should("have.class", "dark");
61
62
  cy.get(".feature-section").should("have.class", "reverse");
62
63
  });
64
+
65
+ it("has padding-block-start 0 when following a title", () => {
66
+ const TestWrapper = {
67
+ render() {
68
+ return h("div", [
69
+ h("h2", "Products & Services"),
70
+ h("p", "Subtitle paragraph"),
71
+ h(FeatureSection, defaultProps),
72
+ ]);
73
+ },
74
+ };
75
+
76
+ cy.mount(TestWrapper);
77
+ cy.get(".feature-section").should("have.css", "padding-top", "0px");
78
+ });
63
79
  });
@@ -107,4 +107,16 @@ describe("<Footer />", () => {
107
107
  .should("have.attr", "href", "https://eox.at")
108
108
  .and("contain", "EOX");
109
109
  });
110
+
111
+ it("defines hover underline styles for footer links", () => {
112
+ cy.mount(Footer);
113
+ cy.get("footer .link").should("have.css", "text-decoration-line", "none");
114
+ cy.document().then((doc) => {
115
+ const styles = Array.from(doc.querySelectorAll("style"))
116
+ .map((s) => s.textContent)
117
+ .join("\n");
118
+ expect(styles).to.include("footer .link:hover");
119
+ expect(styles).to.include("text-decoration: underline");
120
+ });
121
+ });
110
122
  });
@@ -136,10 +136,17 @@ footer .grid > div:not(:first-child) p,
136
136
  margin: 0 !important;
137
137
  }
138
138
 
139
+ footer a:not(.button),
139
140
  footer .link {
140
141
  text-decoration: none;
141
142
  }
142
143
 
144
+ footer a:not(.button):hover,
145
+ footer .link:hover {
146
+ text-decoration: underline;
147
+ text-underline-offset: 2px;
148
+ }
149
+
143
150
  .footer-columns {
144
151
  columns: 1;
145
152
  column-gap: 2rem;
@@ -46,3 +46,10 @@ defineProps({
46
46
  const route = useRoute();
47
47
  const { theme } = useData();
48
48
  </script>
49
+
50
+ <style scoped>
51
+ a,
52
+ a:hover {
53
+ text-decoration: none !important;
54
+ }
55
+ </style>
@@ -60,3 +60,10 @@ defineProps({
60
60
  const route = useRoute();
61
61
  const { theme } = useData();
62
62
  </script>
63
+
64
+ <style scoped>
65
+ a,
66
+ a:hover {
67
+ text-decoration: none !important;
68
+ }
69
+ </style>
@@ -5,10 +5,7 @@ describe("<TestimonialsSection />", () => {
5
5
  cy.mount(TestimonialsSection);
6
6
 
7
7
  // Verify title
8
- cy.get(".section-display-title").should(
9
- "contain",
10
- "What our customers are saying",
11
- );
8
+ cy.get(".headline h2").should("contain", "What our customers are saying");
12
9
 
13
10
  // Verify first testimonial quote
14
11
  cy.get(".quote-text").should(
@@ -61,7 +58,7 @@ describe("<TestimonialsSection />", () => {
61
58
  });
62
59
 
63
60
  // Check title
64
- cy.get(".section-display-title").should("contain", customTitle);
61
+ cy.get(".headline h2").should("contain", customTitle);
65
62
 
66
63
  // Check first custom testimonial
67
64
  cy.get(".quote-text").should("contain", "This is a custom quote");
package/src/helpers.js CHANGED
@@ -1,28 +1,157 @@
1
1
  import { watch } from "vue";
2
2
 
3
3
  /**
4
- * Track Matomo event with array of four primary components
4
+ * Normalizes a URL / path for tracking by:
5
+ * - Removing trailing slash (e.g. `/explore/` -> `/explore`, except for root `/`)
6
+ * - Stripping transient / high-cardinality UI params (e.g., coordinates `x`, `y`, `z`)
7
+ * - Sorting remaining query parameters alphabetically
5
8
  *
6
- * - Category (Required) – This describes the type of events you want to track. For example, Link Clicks, Videos, Outbound Links, and Form Events.
7
- * - Action (Required) – This is the specific action that is taken. For example, with the Video category, you might have a Play, Pause and Complete action.
8
- * - Name (Optional – Recommended) – This is usually the title of the element that is being interacted with, to aid with analysis. For example, it could be the name of a Video that was played or the specific form that is being submitted.
9
- * - Value (Optional) – This is a numeric value and is often added dynamically. It could be the cost of a product that is added to a cart, or the completion percentage of a video.
9
+ * @param {string} inputUrl - The URL or relative path to normalize
10
+ * @param {Object} [options] - Normalization options
11
+ * @param {Array<string>} [options.ignoredParams] - Query parameter keys to strip
12
+ * @param {boolean} [options.stripTrailingSlash=true] - Whether to strip trailing slashes
13
+ * @returns {string} Normalized URL / path
14
+ */
15
+ export const normalizeTrackingUrl = (inputUrl, options = {}) => {
16
+ if (!inputUrl || typeof inputUrl !== "string") return inputUrl;
17
+ const { ignoredParams = ["x", "y", "z"], stripTrailingSlash = true } =
18
+ options;
19
+
20
+ try {
21
+ const base =
22
+ typeof window !== "undefined" && window.location?.href
23
+ ? window.location.href
24
+ : "https://eox.at";
25
+ const url = new URL(inputUrl, base);
26
+ let pathname = url.pathname;
27
+
28
+ if (stripTrailingSlash && pathname.length > 1 && pathname.endsWith("/")) {
29
+ pathname = pathname.replace(/\/+$/, "");
30
+ }
31
+
32
+ const searchParams = new URLSearchParams(url.search);
33
+ for (const key of ignoredParams) {
34
+ searchParams.delete(key);
35
+ }
36
+ searchParams.sort();
37
+
38
+ const search = searchParams.toString();
39
+ const isRelativeInput = !/^https?:\/\//i.test(inputUrl);
40
+
41
+ if (isRelativeInput) {
42
+ return pathname + (search ? `?${search}` : "");
43
+ }
44
+ url.pathname = pathname;
45
+ url.search = search ? `?${search}` : "";
46
+ return url.toString();
47
+ } catch {
48
+ if (stripTrailingSlash && inputUrl.length > 1 && inputUrl.endsWith("/")) {
49
+ return inputUrl.replace(/\/+$/, "");
50
+ }
51
+ return inputUrl;
52
+ }
53
+ };
54
+
55
+ /**
56
+ * Track Matomo event with array or positional arguments of four primary components:
57
+ * Category, Action, Name (optional), Value (optional)
58
+ *
59
+ * Automatically normalizes any path parameters (e.g., Action or Name).
10
60
  *
11
61
  * See https://matomo.org/faq/reports/the-anatomy-of-an-event/
12
62
  *
13
- * @param {Array<string>} eventDetails
63
+ * @param {Array<string|number>|string} eventDetails - Array of event details or Category string
64
+ * @param {...*} rest - Remaining event details if passed positionally
65
+ */
66
+ export const trackEvent = (eventDetails, ...rest) => {
67
+ if (typeof window === "undefined") return;
68
+ const _paq = (window._paq = window._paq || []);
69
+
70
+ const details = Array.isArray(eventDetails)
71
+ ? [...eventDetails]
72
+ : [eventDetails, ...rest];
73
+
74
+ if (typeof details[1] === "string" && details[1].startsWith("/")) {
75
+ details[1] = normalizeTrackingUrl(details[1]);
76
+ }
77
+ if (typeof details[2] === "string" && details[2].startsWith("/")) {
78
+ details[2] = normalizeTrackingUrl(details[2]);
79
+ }
80
+
81
+ _paq.push(["trackEvent", ...details]);
82
+ };
83
+
84
+ /**
85
+ * Track page view with normalized URL and standardized document title.
86
+ *
87
+ * @param {Object|string} [options] - Router instance, options object, or path string
88
+ * @param {string} [options.path] - Custom path to track
89
+ * @param {string} [options.title] - Custom document title to track
90
+ * @param {Object} [options.route] - VitePress router route object
14
91
  */
15
- export const trackEvent = (eventDetails) => {
16
- if (!import.meta.env.SSR) {
17
- _paq.push(["trackEvent", ...eventDetails]);
92
+ export const trackPageView = (options = {}) => {
93
+ if (typeof window === "undefined") return;
94
+ const _paq = (window._paq = window._paq || []);
95
+
96
+ let rawPath = "";
97
+ let rawTitle = "";
98
+
99
+ if (typeof options === "string") {
100
+ rawPath = options;
101
+ } else if (options && typeof options === "object") {
102
+ if (options.route) {
103
+ rawPath = options.route.path;
104
+ rawTitle = options.route.data?.title;
105
+ } else {
106
+ rawPath =
107
+ options.path ||
108
+ options.customUrl ||
109
+ (typeof window !== "undefined"
110
+ ? window.location.pathname + window.location.search
111
+ : "");
112
+ rawTitle =
113
+ options.title ||
114
+ options.customTitle ||
115
+ (typeof document !== "undefined" ? document.title : "");
116
+ }
117
+ }
118
+
119
+ const normalizedUrl = normalizeTrackingUrl(rawPath);
120
+ if (normalizedUrl) {
121
+ _paq.push(["setCustomUrl", normalizedUrl]);
18
122
  }
123
+ if (rawTitle) {
124
+ _paq.push(["setDocumentTitle", rawTitle.trim()]);
125
+ }
126
+ _paq.push(["trackPageView"]);
127
+ _paq.push(["enableLinkTracking"]);
128
+ };
129
+
130
+ /**
131
+ * Track site search keyword query in Matomo.
132
+ *
133
+ * @param {string} keyword - Searched term
134
+ * @param {string|boolean} [category=false] - Search category (e.g. 'stories', 'indicators')
135
+ * @param {number} [resultsCount=0] - Number of search results found
136
+ */
137
+ export const trackSiteSearch = (
138
+ keyword,
139
+ category = false,
140
+ resultsCount = 0,
141
+ ) => {
142
+ if (typeof window === "undefined") return;
143
+ const _paq = (window._paq = window._paq || []);
144
+ _paq.push(["trackSiteSearch", keyword, category, resultsCount]);
19
145
  };
20
146
 
21
147
  /**
22
148
  * Accept cookies
23
149
  */
24
150
  export const acceptCookies = (router) => {
25
- _paq.push(["rememberCookieConsentGiven"]);
151
+ if (typeof window !== "undefined") {
152
+ const _paq = (window._paq = window._paq || []);
153
+ _paq.push(["rememberCookieConsentGiven"]);
154
+ }
26
155
  showBanner(false);
27
156
  enableTracking(true, router);
28
157
  };
@@ -31,8 +160,11 @@ export const acceptCookies = (router) => {
31
160
  * Decline cookies
32
161
  */
33
162
  export const declineCookies = (router) => {
34
- _paq.push(["forgetCookieConsentGiven"]);
35
- _paq.push(["optUserOut"]);
163
+ if (typeof window !== "undefined") {
164
+ const _paq = (window._paq = window._paq || []);
165
+ _paq.push(["forgetCookieConsentGiven"]);
166
+ _paq.push(["optUserOut"]);
167
+ }
36
168
  showBanner(false);
37
169
  enableTracking(false, router);
38
170
  };
@@ -60,17 +192,24 @@ export const enableTracking = (enabled, router) => {
60
192
  };
61
193
 
62
194
  /**
63
- * Track page scrolling depth
195
+ * Track page scrolling depth with normalized path and numeric depth values.
64
196
  *
65
197
  * @param enabled Boolean
198
+ * @param router VitePress router
66
199
  */
67
200
  export const trackPageScrolling = (enabled, router) => {
68
201
  function getScrollPercent() {
69
- const h = document.documentElement,
70
- b = document.body,
71
- st = "scrollTop",
72
- sh = "scrollHeight";
73
- return ((h[st] || b[st]) / ((h[sh] || b[sh]) - h.clientHeight)) * 100;
202
+ try {
203
+ const h = document.documentElement,
204
+ b = document.body,
205
+ st = "scrollTop",
206
+ sh = "scrollHeight";
207
+ const scrollable = (h[sh] || b[sh]) - h.clientHeight;
208
+ if (scrollable <= 0) return 0;
209
+ return ((h[st] || b[st]) / scrollable) * 100;
210
+ } catch {
211
+ return 0;
212
+ }
74
213
  }
75
214
  const scrollTargets = {
76
215
  25: [],
@@ -79,17 +218,18 @@ export const trackPageScrolling = (enabled, router) => {
79
218
  100: [],
80
219
  };
81
220
  const scrollListener = () => {
221
+ const currentPath = normalizeTrackingUrl(router.route.path);
82
222
  Object.keys(scrollTargets).forEach((target) => {
83
223
  if (
84
- getScrollPercent() >= parseInt(target) &&
85
- !scrollTargets[target].includes(router.route.path)
224
+ getScrollPercent() >= parseInt(target, 10) &&
225
+ !scrollTargets[target].includes(currentPath)
86
226
  ) {
87
- scrollTargets[target].push(router.route.path);
227
+ scrollTargets[target].push(currentPath);
88
228
  trackEvent([
89
229
  "Interaction",
90
230
  "Scroll Depth",
91
- router.route.path,
92
- `${target}%`,
231
+ currentPath,
232
+ parseInt(target, 10),
93
233
  ]);
94
234
  }
95
235
  });
@@ -102,19 +242,16 @@ export const trackPageScrolling = (enabled, router) => {
102
242
  };
103
243
 
104
244
  /**
105
- * Track user navigation from one page to another
245
+ * Track user navigation from one page to another with normalized URLs.
106
246
  *
107
- * @param enabled Boolean
247
+ * @param router VitePress router
108
248
  */
109
249
  export const trackRouterNavigation = (router) => {
110
250
  const trackNavigation = () => {
111
- if (!document.cookie.includes("mtm_cookie_consent")) {
112
- return;
113
- }
114
- _paq.push(["setCustomUrl", router.route.path]);
115
- _paq.push(["setDocumentTitle", router.route.data.title]);
116
- _paq.push(["trackPageView"]);
117
- _paq.push(["enableLinkTracking"]);
251
+ trackPageView({
252
+ path: router.route.path,
253
+ title: router.route.data?.title,
254
+ });
118
255
  };
119
256
  watch(() => router.route.data.relativePath, trackNavigation, {
120
257
  immediate: true,