@eox/pages-theme-eox 1.9.0 → 2.0.0-alpha.10

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 (53) hide show
  1. package/.gitlab-ci.yml +4 -1
  2. package/README.md +88 -11
  3. package/cypress/support/mocks/authors.data.js +14 -0
  4. package/cypress/support/mocks/helpers.js +65 -4
  5. package/cypress/support/mocks/vitepress.js +8 -1
  6. package/cypress.config.js +14 -0
  7. package/package.json +3 -1
  8. package/src/Layout.vue +69 -1
  9. package/src/authors.data.js +14 -0
  10. package/src/components/BlogList.vue +655 -0
  11. package/src/components/BlogPost.vue +472 -0
  12. package/src/components/CTASection.vue +18 -4
  13. package/src/components/FeatureCard/Index.vue +462 -31
  14. package/src/components/FeatureCard/Poster.vue +25 -3
  15. package/src/components/FeatureSection.cy.js +16 -0
  16. package/src/components/FeatureSection.vue +3 -8
  17. package/src/components/FeaturesGallery.vue +34 -2
  18. package/src/components/Footer.cy.js +52 -2
  19. package/src/components/Footer.vue +17 -5
  20. package/src/components/HeroSection.cy.js +20 -0
  21. package/src/components/HeroSection.vue +140 -25
  22. package/src/components/ImageCarousel.vue +11 -2
  23. package/src/components/ImageElement.vue +16 -4
  24. package/src/components/ImageGallery.vue +29 -4
  25. package/src/components/LogoSection.vue +26 -3
  26. package/src/components/MobileNavDropdown.vue +13 -3
  27. package/src/components/NavBar.cy.js +15 -0
  28. package/src/components/NavBar.vue +149 -21
  29. package/src/components/NavDropdown.vue +12 -2
  30. package/src/components/OpenPositions.vue +292 -0
  31. package/src/components/SectionHeader.cy.js +54 -0
  32. package/src/components/SectionHeader.vue +115 -0
  33. package/src/components/TagList.vue +311 -0
  34. package/src/components/TeamPage.vue +347 -0
  35. package/src/components/TestimonialsSection.cy.js +76 -0
  36. package/src/components/TestimonialsSection.vue +368 -0
  37. package/src/components/TextSection.cy.js +115 -0
  38. package/src/components/TextSection.vue +396 -0
  39. package/src/helpers.js +182 -37
  40. package/src/helpers.test.js +181 -0
  41. package/src/index.js +16 -0
  42. package/src/pages/author/[author].md +5 -0
  43. package/src/pages/author/[author].paths.js +60 -0
  44. package/src/pages/blog.md +5 -0
  45. package/src/pages/tag/[tag].md +5 -0
  46. package/src/pages/tag/[tag].paths.js +49 -0
  47. package/src/pages/tag.md +5 -0
  48. package/src/pages/team.md +5 -0
  49. package/src/posts.data.js +134 -0
  50. package/src/style.css +389 -8
  51. package/src/tagUtils.js +148 -0
  52. package/src/team.data.js +1 -1
  53. package/src/vitepressConfig.mjs +101 -1
@@ -0,0 +1,396 @@
1
+ <template>
2
+ <section
3
+ class="text-section full-width"
4
+ :class="{
5
+ 'is-reverse': reverse,
6
+ 'no-image': !image && !$slots.media,
7
+ 'is-compact': compact,
8
+ }"
9
+ >
10
+ <div class="holder">
11
+ <div
12
+ class="text-section-grid"
13
+ :class="{
14
+ 'grid-reverse': reverse,
15
+ 'grid-single': !image && !$slots.media,
16
+ }"
17
+ >
18
+ <!-- Text Column -->
19
+ <div class="text-column">
20
+ <span v-if="tagline" class="tagline">{{ tagline }}</span>
21
+ <h2 v-if="title" class="section-title">{{ title }}</h2>
22
+ <div class="section-content">
23
+ <slot></slot>
24
+ </div>
25
+ <!-- Prop-based button -->
26
+ <div v-if="buttonText && buttonLink" class="section-actions">
27
+ <a
28
+ :href="resolvedButtonLink"
29
+ :target="resolvedButtonTarget"
30
+ class="button"
31
+ @click="
32
+ trackEvent([
33
+ 'TextSection',
34
+ 'Click',
35
+ title || buttonText,
36
+ buttonText,
37
+ ])
38
+ "
39
+ >
40
+ <span>{{ buttonText }}</span>
41
+ <i v-if="buttonIcon" :class="buttonIcon" class="btn-icon"></i>
42
+ </a>
43
+ </div>
44
+ <slot name="actions"></slot>
45
+ </div>
46
+
47
+ <!-- Media Column -->
48
+ <div v-if="image || $slots.media" class="media-column">
49
+ <slot name="media">
50
+ <img
51
+ v-if="image"
52
+ :src="resolvedImage"
53
+ :alt="imageAlt || title || ''"
54
+ class="section-image"
55
+ loading="lazy"
56
+ />
57
+ </slot>
58
+ </div>
59
+ </div>
60
+ </div>
61
+ </section>
62
+ </template>
63
+
64
+ <script setup>
65
+ import { withBase } from "vitepress";
66
+ import { computed } from "vue";
67
+ import { trackEvent } from "../helpers.js";
68
+
69
+ const props = defineProps({
70
+ title: {
71
+ type: String,
72
+ default: "",
73
+ },
74
+ tagline: {
75
+ type: String,
76
+ default: "",
77
+ },
78
+ image: {
79
+ type: String,
80
+ default: "",
81
+ },
82
+ imageAlt: {
83
+ type: String,
84
+ default: "",
85
+ },
86
+ reverse: {
87
+ type: Boolean,
88
+ default: false,
89
+ },
90
+ buttonText: {
91
+ type: String,
92
+ default: "",
93
+ },
94
+ buttonLink: {
95
+ type: String,
96
+ default: "",
97
+ },
98
+ buttonTarget: {
99
+ type: String,
100
+ default: "",
101
+ },
102
+ buttonIcon: {
103
+ type: String,
104
+ default: "",
105
+ },
106
+ compact: {
107
+ type: Boolean,
108
+ default: false,
109
+ },
110
+ });
111
+
112
+ function resolveUrl(url) {
113
+ if (!url) return "";
114
+ if (
115
+ url.startsWith("http://") ||
116
+ url.startsWith("https://") ||
117
+ url.startsWith("//") ||
118
+ url.startsWith("#")
119
+ ) {
120
+ return url;
121
+ }
122
+ return withBase(url);
123
+ }
124
+
125
+ const resolvedImage = computed(() => resolveUrl(props.image));
126
+ const resolvedButtonLink = computed(() => resolveUrl(props.buttonLink));
127
+ const resolvedButtonTarget = computed(() => {
128
+ if (props.buttonTarget) return props.buttonTarget;
129
+ if (
130
+ props.buttonLink &&
131
+ (props.buttonLink.startsWith("http://") ||
132
+ props.buttonLink.startsWith("https://") ||
133
+ props.buttonLink.startsWith("//"))
134
+ ) {
135
+ return "_blank";
136
+ }
137
+ return "_self";
138
+ });
139
+ </script>
140
+
141
+ <style scoped>
142
+ .full-width {
143
+ width: 100svw;
144
+ position: relative;
145
+ left: 50%;
146
+ right: 50%;
147
+ margin-left: -50svw;
148
+ margin-right: -50svw;
149
+ max-inline-size: unset !important;
150
+ }
151
+
152
+ .text-section {
153
+ padding-block-start: 56px;
154
+ padding-block-end: 80px;
155
+ display: flex;
156
+ justify-content: center;
157
+ background: transparent;
158
+ color: var(--on-surface, #020617);
159
+ }
160
+
161
+ .text-section.is-compact {
162
+ padding-block-start: 28px;
163
+ padding-block-end: 40px;
164
+ }
165
+
166
+ /* When text-sections are placed consecutively, tighten spacing and only apply extra end padding to the last one */
167
+ :deep(.text-section:has(+ .text-section)),
168
+ .text-section:has(+ .text-section) {
169
+ padding-block-end: 56px;
170
+ }
171
+
172
+ :deep(.text-section.is-compact:has(+ .text-section)),
173
+ .text-section.is-compact:has(+ .text-section) {
174
+ padding-block-end: 28px;
175
+ }
176
+
177
+ :deep(.text-section + .text-section),
178
+ .text-section + .text-section {
179
+ padding-block-start: 16px;
180
+ }
181
+
182
+ .text-section .holder {
183
+ width: 100%;
184
+ max-width: 1200px;
185
+ margin-left: auto;
186
+ margin-right: auto;
187
+ padding-inline: 24px;
188
+ }
189
+
190
+ .text-section-grid {
191
+ display: grid;
192
+ grid-template-columns: 1fr;
193
+ gap: 2rem;
194
+ align-items: center;
195
+ }
196
+
197
+ /* On mobile (< 1024px), the media/image column is always at the start before the text */
198
+ .text-section-grid .media-column {
199
+ order: 1;
200
+ }
201
+
202
+ .text-section-grid .text-column {
203
+ order: 2;
204
+ }
205
+
206
+ @media (min-width: 1024px) {
207
+ .text-section-grid {
208
+ grid-template-columns: 1fr 1fr;
209
+ gap: 4rem;
210
+ }
211
+
212
+ /* Default on desktop: Text on left (1), Media on right (2) */
213
+ .text-section-grid .text-column {
214
+ order: 1;
215
+ }
216
+
217
+ .text-section-grid .media-column {
218
+ order: 2;
219
+ }
220
+
221
+ /* Reverse on desktop: Media on left (1), Text on right (2) */
222
+ .text-section-grid.grid-reverse .text-column {
223
+ order: 2;
224
+ }
225
+
226
+ .text-section-grid.grid-reverse .media-column {
227
+ order: 1;
228
+ }
229
+
230
+ .text-section-grid.grid-single {
231
+ grid-template-columns: 1fr;
232
+ max-width: 680px;
233
+ }
234
+ }
235
+
236
+ .text-column {
237
+ display: flex;
238
+ flex-direction: column;
239
+ width: 100%;
240
+ }
241
+
242
+ .tagline {
243
+ display: inline-block;
244
+ font-size: 0.875rem;
245
+ font-weight: 600;
246
+ text-transform: uppercase;
247
+ letter-spacing: 0.05em;
248
+ color: var(--primary, #004170);
249
+ margin-bottom: 0.5rem;
250
+ }
251
+
252
+ .section-title {
253
+ font-family: inherit;
254
+ font-weight: 700;
255
+ font-size: 2rem;
256
+ line-height: 1.25;
257
+ color: #020617;
258
+ margin-top: 0;
259
+ margin-bottom: 1.25rem;
260
+ border-top: none;
261
+ }
262
+
263
+ /* Markdown Content Styling */
264
+ .section-content :deep(h2) {
265
+ font-family: inherit;
266
+ font-weight: 700;
267
+ font-size: 2rem;
268
+ line-height: 1.25;
269
+ color: #020617;
270
+ margin-top: 0;
271
+ margin-bottom: 1.25rem;
272
+ border-top: none;
273
+ }
274
+
275
+ .section-content :deep(h3) {
276
+ font-family: inherit;
277
+ font-weight: 700;
278
+ font-size: 1.5rem;
279
+ line-height: 1.35;
280
+ color: #020617;
281
+ margin-top: 1.5rem;
282
+ margin-bottom: 0.75rem;
283
+ border-top: none;
284
+ }
285
+
286
+ .section-content :deep(h4),
287
+ .section-content :deep(h5) {
288
+ font-family: inherit;
289
+ font-weight: 600;
290
+ font-size: 1.125rem;
291
+ line-height: 1.4;
292
+ color: #020617;
293
+ margin-top: 1.25rem;
294
+ margin-bottom: 0.5rem;
295
+ }
296
+
297
+ .section-content :deep(p) {
298
+ font-size: 1rem;
299
+ line-height: 1.65;
300
+ color: #334155;
301
+ margin-top: 0;
302
+ margin-bottom: 1rem;
303
+ }
304
+
305
+ .section-content :deep(ul) {
306
+ margin-top: 0.75rem;
307
+ margin-bottom: 1.25rem;
308
+ padding-left: 1.25rem;
309
+ list-style-type: disc;
310
+ }
311
+
312
+ .section-content :deep(li) {
313
+ font-size: 0.95rem;
314
+ line-height: 1.6;
315
+ color: #334155;
316
+ margin-bottom: 0.5rem;
317
+ }
318
+
319
+ .section-content :deep(li strong) {
320
+ color: #020617;
321
+ font-weight: 700;
322
+ }
323
+
324
+ .section-content :deep(a:not(.button)) {
325
+ color: #0284c7;
326
+ text-decoration: none;
327
+ font-weight: 500;
328
+ transition: color 0.15s ease-in-out;
329
+ }
330
+
331
+ .section-content :deep(a:not(.button):hover) {
332
+ text-decoration: underline;
333
+ color: #0369a1;
334
+ }
335
+
336
+ /* Pill Button Styling - for both prop and markdown links with .button */
337
+ .section-actions {
338
+ margin-top: 0.75rem;
339
+ margin-bottom: 0.5rem;
340
+ }
341
+
342
+ .button,
343
+ .section-content :deep(a.button),
344
+ .section-content :deep(.button) {
345
+ display: inline-flex;
346
+ align-items: center;
347
+ justify-content: center;
348
+ gap: 0.5rem;
349
+ padding: 0.5rem 1.5rem;
350
+ border-radius: 9999px;
351
+ border: 1px solid #020617;
352
+ background-color: #ffffff;
353
+ color: #020617 !important;
354
+ font-weight: 600;
355
+ font-size: 0.9375rem;
356
+ line-height: 1.5;
357
+ text-decoration: none !important;
358
+ transition: all 0.2s ease-in-out;
359
+ cursor: pointer;
360
+ width: fit-content;
361
+ }
362
+
363
+ .button:hover,
364
+ .section-content :deep(a.button:hover),
365
+ .section-content :deep(.button:hover) {
366
+ background-color: #f1f5f9;
367
+ border-color: #020617;
368
+ color: #020617 !important;
369
+ }
370
+
371
+ .btn-icon {
372
+ font-size: 1.1em;
373
+ display: inline-flex;
374
+ }
375
+
376
+ /* Media Column & Image Styling */
377
+ .media-column {
378
+ display: flex;
379
+ justify-content: center;
380
+ align-items: center;
381
+ width: 100%;
382
+ }
383
+
384
+ .section-image {
385
+ width: 100%;
386
+ max-width: 100%;
387
+ height: auto;
388
+ border-radius: 16px;
389
+ border: 1px solid rgba(15, 23, 42, 0.08);
390
+ box-shadow:
391
+ 0 4px 16px -2px rgba(15, 23, 42, 0.06),
392
+ 0 2px 6px -2px rgba(15, 23, 42, 0.04);
393
+ background-color: #ffffff;
394
+ display: block;
395
+ }
396
+ </style>
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
+ }
18
117
  }
118
+
119
+ const normalizedUrl = normalizeTrackingUrl(rawPath);
120
+ if (normalizedUrl) {
121
+ _paq.push(["setCustomUrl", normalizedUrl]);
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,
@@ -146,6 +283,8 @@ export const getFlatList = (items) => {
146
283
  * @returns {boolean}
147
284
  */
148
285
  export const isActive = (item, path) => {
286
+ if (!item || !path) return false;
287
+
149
288
  if (item.activeMatch) {
150
289
  try {
151
290
  return new RegExp(item.activeMatch).test(path);
@@ -155,20 +294,26 @@ export const isActive = (item, path) => {
155
294
  }
156
295
  }
157
296
 
158
- if (item.items) {
159
- return item.items.some((sub) => isActive(sub, path));
297
+ if (item.items && item.items.some((sub) => isActive(sub, path))) {
298
+ return true;
160
299
  }
161
300
 
162
301
  if (item.link) {
163
- const linkPath = item.link.replace(/\.html$/, "").replace(/\/$/, "");
164
- const currentPath = path.replace(/\.html$/, "").replace(/\/$/, "");
302
+ const normalize = (p) =>
303
+ decodeURI(p)
304
+ .replace(/[?#].*$/, "")
305
+ .replace(/\.html$/, "")
306
+ .replace(/\/$/, "");
307
+
308
+ const linkPath = normalize(item.link);
309
+ const currentPath = normalize(path);
165
310
 
166
311
  if (linkPath === currentPath) return true;
167
312
 
168
313
  // Special case for home page to avoid matching everything
169
314
  if (linkPath === "") return currentPath === "";
170
315
 
171
- return currentPath.startsWith(linkPath);
316
+ return currentPath === linkPath || currentPath.startsWith(linkPath + "/");
172
317
  }
173
318
 
174
319
  return false;