@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,472 @@
1
+ <template>
2
+ <div class="blog-post-page VPDoc max-w-[1000px] mx-auto px-6 md:px-8">
3
+ <div class="blog-post-layout">
4
+ <!-- Main Article Column -->
5
+ <div class="blog-content-col">
6
+ <!-- Cover Image -->
7
+ <div
8
+ v-if="coverImage"
9
+ class="mb-8 rounded-2xl overflow-hidden shadow-sm max-h-[550px] bg-slate-50 border border-slate-100"
10
+ >
11
+ <img
12
+ :src="getCoverImageUrl(coverImage)"
13
+ :alt="frontmatter.title"
14
+ class="w-full h-full object-cover max-h-[550px]"
15
+ />
16
+ </div>
17
+
18
+ <!-- Title -->
19
+ <h1 class="blog-post-title">
20
+ {{ frontmatter.title }}
21
+ </h1>
22
+
23
+ <!-- Metadata Row (Date & Authors) and Tags Underneath -->
24
+ <div class="mb-8 border-b border-slate-100 pb-6">
25
+ <div class="blog-metadata">
26
+ <!-- Date Span -->
27
+ <span v-if="frontmatter.date" class="metadata-item">
28
+ <i class="mdi mdi-clock-outline metadata-icon"></i>
29
+ <time :datetime="frontmatter.date" class="metadata-text">
30
+ {{ formatDate(frontmatter.date) }}
31
+ </time>
32
+ </span>
33
+
34
+ <!-- Author Span -->
35
+ <span v-if="authors && authors.length" class="metadata-item">
36
+ <i class="mdi mdi-account-outline metadata-icon"></i>
37
+ <span class="metadata-text label-text">By</span>
38
+ <span
39
+ v-for="(authorId, idx) in authors"
40
+ :key="authorId"
41
+ class="author-item"
42
+ >
43
+ <a
44
+ :href="withBase(`/author/${authorId}/`)"
45
+ class="author-link-active"
46
+ >
47
+ {{ getAuthorName(authorId) }}
48
+ </a>
49
+ <span v-if="idx < authors.length - 1" class="author-separator"
50
+ >,</span
51
+ >
52
+ </span>
53
+ </span>
54
+ </div>
55
+
56
+ <!-- Tags right underneath -->
57
+ <div
58
+ v-if="frontmatter.tags && frontmatter.tags.length"
59
+ class="post-tags-container"
60
+ >
61
+ <a
62
+ v-for="tag in frontmatter.tags"
63
+ :key="tag"
64
+ :href="withBase(`/tag/${tag}/`)"
65
+ :style="getTagStyle(tag)"
66
+ class="post-tag"
67
+ >
68
+ {{ tag }}
69
+ </a>
70
+ </div>
71
+ </div>
72
+
73
+ <!-- Main Content -->
74
+ <div class="vp-doc prose prose-slate max-w-none">
75
+ <Content />
76
+ </div>
77
+ </div>
78
+
79
+ <!-- Sidebar Column -->
80
+ <div class="blog-sidebar-col">
81
+ <!-- Table of Contents (On This Page) -->
82
+ <div v-if="headers && headers.length" class="toc-container">
83
+ <VPDocAsideOutline />
84
+ </div>
85
+
86
+ <!-- Hot Tags -->
87
+ <div class="sidebar-widget">
88
+ <h3 class="sidebar-title">Hot tags</h3>
89
+ <div class="sidebar-tags-container">
90
+ <a
91
+ v-for="tag in calculatedHotTags"
92
+ :key="tag.name"
93
+ :href="withBase(`/tag/${tag.name}/`)"
94
+ :style="getTagStyle(tag.name)"
95
+ class="sidebar-tag"
96
+ >
97
+ {{ tag.name }} ({{ tag.count }})
98
+ </a>
99
+ </div>
100
+ </div>
101
+
102
+ <!-- Recent Posts -->
103
+ <div class="sidebar-widget">
104
+ <h3 class="sidebar-title">Recent posts</h3>
105
+ <div class="space-y-4">
106
+ <div
107
+ v-for="post in calculatedRecentPosts"
108
+ :key="post.url"
109
+ class="flex flex-col gap-1 border-b border-slate-100 last:border-none pb-3 last:pb-0"
110
+ >
111
+ <a
112
+ :href="withBase(post.url)"
113
+ class="text-sm font-semibold text-slate-900 hover:text-primary leading-snug transition-colors line-clamp-2"
114
+ >
115
+ {{ post.title }}
116
+ </a>
117
+ <time :datetime="post.date" class="text-xs text-slate-500">
118
+ {{ formatDate(post.date) }}
119
+ </time>
120
+ </div>
121
+ </div>
122
+ </div>
123
+ </div>
124
+ </div>
125
+ </div>
126
+ </template>
127
+
128
+ <script setup>
129
+ import { useData, withBase } from "vitepress";
130
+ import { computed } from "vue";
131
+ import { data as posts } from "../posts.data.js";
132
+ import { data as authorsList } from "../authors.data.js";
133
+ import { getTagColor, getTagStyle } from "../tagUtils.js";
134
+ import VPDocAsideOutline from "vitepress/dist/client/theme-default/components/VPDocAsideOutline.vue";
135
+
136
+ const { frontmatter, page } = useData();
137
+
138
+ // Set default outline level to deep for blog posts so h3 headers are resolved
139
+ if (frontmatter.value && frontmatter.value.outline === undefined) {
140
+ frontmatter.value.outline = "deep";
141
+ }
142
+
143
+ // Extract headers
144
+ const headers = computed(() => {
145
+ return page.value.headers || [];
146
+ });
147
+
148
+ // Form cover image URL correctly (prefixing filename with /images/)
149
+ function getCoverImageUrl(coverImage) {
150
+ if (!coverImage) return "";
151
+ if (coverImage.startsWith("http") || coverImage.startsWith("//")) {
152
+ return coverImage;
153
+ }
154
+ const base = withBase("/");
155
+ if (base && base !== "/" && coverImage.startsWith(base)) {
156
+ return coverImage;
157
+ }
158
+ const path = coverImage.startsWith("/")
159
+ ? coverImage
160
+ : `/images/${coverImage}`;
161
+ return withBase(path);
162
+ }
163
+
164
+ // Format publication date
165
+ function formatDate(dateStr) {
166
+ if (!dateStr) return "";
167
+ const date = new Date(dateStr);
168
+ return date.toLocaleDateString("en-US", {
169
+ year: "numeric",
170
+ month: "long",
171
+ day: "numeric",
172
+ });
173
+ }
174
+
175
+ // Extract authors
176
+ const authors = computed(() => {
177
+ const author = frontmatter.value.author || "";
178
+ return Array.isArray(author) ? author : [author];
179
+ });
180
+
181
+ // Get author full name
182
+ function getAuthorName(authorId) {
183
+ const auth = authorsList.find((a) => a.id === authorId);
184
+ return auth ? auth.name : authorId;
185
+ }
186
+
187
+ // Cover image path
188
+ const coverImage = computed(() => {
189
+ return frontmatter.value.coverImage || frontmatter.value.image || "";
190
+ });
191
+
192
+ // Dynamic Recent Posts (excluding current page)
193
+ const calculatedRecentPosts = computed(() => {
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);
207
+ });
208
+
209
+ // Dynamic Hot Tags
210
+ const calculatedHotTags = computed(() => {
211
+ const tagCounts = {};
212
+ posts.forEach((p) => {
213
+ if (Array.isArray(p.tags)) {
214
+ p.tags.forEach((t) => {
215
+ const clean = t.trim();
216
+ if (clean) {
217
+ tagCounts[clean] = (tagCounts[clean] || 0) + 1;
218
+ }
219
+ });
220
+ }
221
+ });
222
+ return Object.entries(tagCounts)
223
+ .map(([name, count]) => ({
224
+ name,
225
+ count,
226
+ color: getTagColor(name),
227
+ }))
228
+ .sort((a, b) => b.count - a.count)
229
+ .slice(0, 10);
230
+ });
231
+ </script>
232
+
233
+ <style scoped>
234
+ .blog-post-page {
235
+ width: 100% !important;
236
+ max-width: 1000px !important;
237
+ margin-left: auto !important;
238
+ margin-right: auto !important;
239
+ padding-left: 1.5rem !important;
240
+ padding-right: 1.5rem !important;
241
+ box-sizing: border-box !important;
242
+ padding-top: calc(var(--vp-nav-height) + 0.5rem) !important;
243
+ padding-bottom: 2rem !important;
244
+ }
245
+
246
+ @media (min-width: 768px) {
247
+ .blog-post-page {
248
+ padding-left: 2rem !important;
249
+ padding-right: 2rem !important;
250
+ padding-top: calc(var(--vp-nav-height) + 1.5rem) !important;
251
+ padding-bottom: 4rem !important;
252
+ }
253
+ }
254
+
255
+ .blog-post-layout {
256
+ display: grid;
257
+ grid-template-columns: 1fr;
258
+ gap: 2.5rem;
259
+ }
260
+
261
+ .blog-content-col {
262
+ width: 100%;
263
+ min-width: 0;
264
+ /* Prevent Beercss style inheritance */
265
+ background: transparent !important;
266
+ box-shadow: none !important;
267
+ border: none !important;
268
+ padding: 0 !important;
269
+ }
270
+
271
+ .blog-sidebar-col {
272
+ width: 100%;
273
+ display: flex;
274
+ flex-direction: column;
275
+ gap: 2.5rem;
276
+ }
277
+
278
+ @media (min-width: 1024px) {
279
+ .blog-post-layout {
280
+ grid-template-columns: minmax(0, 1fr) 280px;
281
+ gap: 4rem;
282
+ align-items: start;
283
+ }
284
+ .blog-sidebar-col {
285
+ position: sticky !important;
286
+ align-self: start !important;
287
+ top: calc(var(--vp-nav-height) + 2rem) !important;
288
+ max-height: calc(100vh - var(--vp-nav-height) - 4rem);
289
+ overflow-y: auto;
290
+ scrollbar-width: none; /* Hide scrollbar for cleaner look */
291
+ }
292
+ .blog-sidebar-col::-webkit-scrollbar {
293
+ display: none;
294
+ }
295
+ }
296
+
297
+ .blog-post-title {
298
+ font-family: "Inter", sans-serif;
299
+ font-style: normal;
300
+ font-weight: 700;
301
+ font-size: 28px;
302
+ line-height: 36px;
303
+ letter-spacing: 0.5px;
304
+ color: #020617; /* slate-950 */
305
+ margin-top: 0;
306
+ margin-bottom: 1.5rem;
307
+ }
308
+
309
+ @media (min-width: 768px) {
310
+ .blog-post-title {
311
+ font-size: 36px;
312
+ line-height: 44px;
313
+ }
314
+ }
315
+
316
+ .blog-metadata {
317
+ display: flex;
318
+ flex-wrap: wrap;
319
+ align-items: center;
320
+ gap: 1.5rem;
321
+ margin-bottom: 1rem;
322
+ }
323
+
324
+ .metadata-item {
325
+ display: inline-flex;
326
+ align-items: center;
327
+ gap: 0.5rem;
328
+ }
329
+
330
+ .metadata-icon {
331
+ font-size: 1.1rem !important;
332
+ color: #64748b;
333
+ display: inline-flex;
334
+ align-items: center;
335
+ justify-content: center;
336
+ height: 100%;
337
+ transform: translateY(
338
+ 0.5px
339
+ ); /* Micro-adjustment for perfect vertical alignment */
340
+ }
341
+
342
+ .metadata-text {
343
+ font-size: 0.875rem;
344
+ color: #64748b;
345
+ line-height: 1.2;
346
+ }
347
+
348
+ .label-text {
349
+ margin-right: -0.125rem;
350
+ }
351
+
352
+ .author-item {
353
+ display: inline-flex;
354
+ align-items: center;
355
+ }
356
+
357
+ .author-link-active {
358
+ font-size: 0.875rem;
359
+ font-weight: 600;
360
+ color: var(--vp-c-brand, #004170) !important;
361
+ text-decoration: none;
362
+ transition:
363
+ color 0.15s ease,
364
+ text-decoration 0.15s ease;
365
+ line-height: 1.2;
366
+ }
367
+
368
+ .author-link-active:hover {
369
+ color: var(--vp-c-brand-next, #002d50) !important;
370
+ text-decoration: underline !important;
371
+ }
372
+
373
+ .author-separator {
374
+ color: #64748b;
375
+ margin-right: 0.25rem;
376
+ font-size: 0.875rem;
377
+ }
378
+
379
+ .sidebar-widget {
380
+ display: flex;
381
+ flex-direction: column;
382
+ gap: 0.75rem;
383
+ }
384
+
385
+ .sidebar-title {
386
+ font-size: 0.75rem;
387
+ font-weight: 600;
388
+ text-transform: uppercase;
389
+ letter-spacing: 0.05em;
390
+ color: #64748b; /* slate-500 */
391
+ border-bottom: 1px solid #f1f5f9; /* slate-100 */
392
+ padding-bottom: 0.5rem;
393
+ margin: 0;
394
+ }
395
+
396
+ .sidebar-tags-container {
397
+ display: flex !important;
398
+ flex-wrap: wrap !important;
399
+ gap: 0.5rem !important;
400
+ margin-top: 0.25rem !important;
401
+ }
402
+
403
+ .sidebar-tag {
404
+ display: inline-flex !important;
405
+ align-items: center !important;
406
+ font-size: 0.75rem !important;
407
+ font-weight: 600 !important;
408
+ padding: 0.25rem 0.75rem !important;
409
+ border-radius: 4px !important;
410
+ text-decoration: none !important;
411
+ line-height: 1.2 !important;
412
+ transition:
413
+ opacity 0.2s ease,
414
+ transform 0.1s ease !important;
415
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05) !important;
416
+ }
417
+
418
+ .sidebar-tag:hover {
419
+ opacity: 0.8 !important;
420
+ transform: translateY(-1px) !important;
421
+ }
422
+
423
+ .post-tags-container {
424
+ display: flex !important;
425
+ flex-wrap: wrap !important;
426
+ gap: 0.5rem !important;
427
+ margin-top: 0.75rem !important;
428
+ }
429
+
430
+ .post-tag {
431
+ display: inline-flex !important;
432
+ align-items: center !important;
433
+ font-size: 0.75rem !important;
434
+ font-weight: 600 !important;
435
+ padding: 0.25rem 0.75rem !important;
436
+ border-radius: 4px !important;
437
+ text-decoration: none !important;
438
+ line-height: 1.2 !important;
439
+ transition:
440
+ opacity 0.2s ease,
441
+ transform 0.1s ease !important;
442
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05) !important;
443
+ }
444
+
445
+ .post-tag:hover {
446
+ opacity: 0.8 !important;
447
+ transform: translateY(-1px) !important;
448
+ }
449
+
450
+ /* Force VPDocAsideOutline to be visible on all screen sizes in our custom layout */
451
+ :deep(.VPDocAsideOutline) {
452
+ display: block !important;
453
+ }
454
+
455
+ :deep(.VPDocAsideOutline.has-outline) {
456
+ display: block !important;
457
+ }
458
+
459
+ .line-clamp-2 {
460
+ display: -webkit-box;
461
+ -webkit-line-clamp: 2;
462
+ -webkit-box-orient: vertical;
463
+ overflow: hidden;
464
+ }
465
+ </style>
466
+
467
+ <style>
468
+ /* Global override for main tag to ensure position: sticky is never blocked on blog pages */
469
+ main {
470
+ overflow: visible !important;
471
+ }
472
+ </style>
@@ -4,7 +4,7 @@
4
4
  :class="
5
5
  dark !== undefined
6
6
  ? 'primary primary-gradient-bg'
7
- : 'surface-container-low'
7
+ : 'surface-container-lowest'
8
8
  "
9
9
  >
10
10
  <div class="large-space"></div>
@@ -18,7 +18,7 @@
18
18
  v-if="primaryButton"
19
19
  class="button extra small-margin responsive-mobile"
20
20
  :class="dark !== undefined ? 'surface' : 'primary'"
21
- :href="primaryLink"
21
+ :href="resolvedPrimaryLink"
22
22
  @click="
23
23
  trackEvent(['CTA', 'Click', 'CTA Section Primary', primaryButton])
24
24
  "
@@ -27,7 +27,7 @@
27
27
  <a
28
28
  v-if="secondaryButton"
29
29
  class="button extra small-margin secondary responsive-mobile"
30
- :href="secondaryLink"
30
+ :href="resolvedSecondaryLink"
31
31
  @click="
32
32
  trackEvent(['CTA', 'Click', 'CTA Section Secondary', secondaryButton])
33
33
  "
@@ -37,7 +37,7 @@
37
37
  v-if="altButton"
38
38
  class="button border extra small-margin responsive-mobile"
39
39
  :class="dark !== undefined ? 'white-text' : ''"
40
- :href="altLink"
40
+ :href="resolvedAltLink"
41
41
  @click="trackEvent(['CTA', 'Click', 'CTA Section Alt', altButton])"
42
42
  >{{ altButton }}</a
43
43
  >
@@ -49,6 +49,8 @@
49
49
  </template>
50
50
 
51
51
  <script setup>
52
+ import { computed } from "vue";
53
+ import { withBase } from "vitepress";
52
54
  import { trackEvent } from "../helpers";
53
55
  const props = defineProps([
54
56
  "dark",
@@ -61,6 +63,18 @@ const props = defineProps([
61
63
  "tagline",
62
64
  "title",
63
65
  ]);
66
+
67
+ function resolveLink(link) {
68
+ if (!link) return "";
69
+ if (link.startsWith("http") || link.startsWith("//")) return link;
70
+ const base = withBase("/");
71
+ if (base && base !== "/" && link.startsWith(base)) return link;
72
+ return link.startsWith("/") ? withBase(link) : withBase("/" + link);
73
+ }
74
+
75
+ const resolvedPrimaryLink = computed(() => resolveLink(props.primaryLink));
76
+ const resolvedSecondaryLink = computed(() => resolveLink(props.secondaryLink));
77
+ const resolvedAltLink = computed(() => resolveLink(props.altLink));
64
78
  </script>
65
79
 
66
80
  <style>