Skip to content

Commit 96727f3

Browse files
authored
Merge branch 'main' into fix/seo-audit-june-2026-2
2 parents 7b63bba + b45e449 commit 96727f3

23 files changed

Lines changed: 629 additions & 187 deletions

public/images/nav/world-map.png

15 KB
Loading
41.5 KB
Loading
30.8 KB
Loading
147 KB
Loading
70.7 KB
Loading

src/components/SecondaryTabNav.astro

Lines changed: 154 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -19,24 +19,60 @@ function normalizePath(p: string): string {
1919
return trimmed === '' ? '/' : trimmed;
2020
}
2121
22-
function isItemActive(item: SecondaryTabNavItem, pathname: string): boolean {
22+
function parseTabHref(href: string): { path: string; hash: string } {
23+
const hashIndex = href.indexOf('#');
24+
if (hashIndex === -1) {
25+
return { path: normalizePath(href), hash: '' };
26+
}
27+
return {
28+
path: normalizePath(href.slice(0, hashIndex)),
29+
hash: href.slice(hashIndex + 1),
30+
};
31+
}
32+
33+
function getTabHashesOnPath(navItems: SecondaryTabNavItem[], itemPath: string): string[] {
34+
return navItems
35+
.map((item) => parseTabHref(item.href))
36+
.filter(({ path, hash }) => path === itemPath && hash !== '')
37+
.map(({ hash }) => hash);
38+
}
39+
40+
function isItemActive(
41+
item: SecondaryTabNavItem,
42+
pathname: string,
43+
currentHash: string,
44+
navItems: SecondaryTabNavItem[]
45+
): boolean {
46+
const { path: itemPath, hash: itemHash } = parseTabHref(item.href);
2347
const path = normalizePath(pathname);
24-
const base = normalizePath(item.href);
48+
2549
if (item.exact) {
26-
return path === base;
50+
if (path !== itemPath) return false;
51+
} else if (path !== itemPath && !path.startsWith(`${itemPath}/`)) {
52+
return false;
53+
}
54+
55+
if (itemHash) {
56+
return currentHash === itemHash;
57+
}
58+
59+
const tabHashes = getTabHashesOnPath(navItems, itemPath);
60+
if (tabHashes.length === 0) {
61+
return true;
2762
}
28-
return path === base || path.startsWith(`${base}/`);
63+
return !tabHashes.includes(currentHash);
2964
}
3065
3166
const currentPath = Astro.url.pathname;
32-
const activeItem = items.find((item) => isItemActive(item, currentPath)) ?? items[0];
67+
const activeItem = items.find((item) => isItemActive(item, currentPath, '', items)) ?? items[0];
3368
const activeLabel = activeItem.label;
3469
3570
const mobileMenuId = `${idPrefix}-mobile-menu`;
3671
const mobileTriggerId = `${idPrefix}-mobile-trigger`;
72+
const hasHashTabs = items.some((item) => parseTabHref(item.href).hash !== '');
3773
---
3874

39-
<nav class="secondary-tab-nav" aria-label={ariaLabel}>
75+
<nav class="secondary-tab-nav" aria-label={ariaLabel} data-hash-tabs={hasHashTabs || undefined}>
4076
<div class="secondary-tab-nav-mobile md:hidden" x-data="{ open: false }">
4177
<button
4278
type="button"
@@ -48,7 +84,9 @@ const mobileTriggerId = `${idPrefix}-mobile-trigger`;
4884
aria-controls={mobileMenuId}
4985
>
5086
<span class="secondary-tab-nav-mobile-label">{mobileLabel}</span>
51-
<span class="secondary-tab-nav-mobile-selected">{activeLabel}</span>
87+
<span class="secondary-tab-nav-mobile-selected" data-secondary-tab-mobile-selected
88+
>{activeLabel}</span
89+
>
5290
<span
5391
class="secondary-tab-nav-mobile-chevron"
5492
:class="{ 'secondary-tab-nav-mobile-chevron--open': open }"
@@ -69,46 +107,122 @@ const mobileTriggerId = `${idPrefix}-mobile-trigger`;
69107
>
70108
<ul class="secondary-tab-nav-mobile-list" role="presentation">
71109
{
72-
items.map((item) => (
73-
<li role="presentation">
74-
<a
75-
href={item.href}
76-
class:list={[
77-
'secondary-tab-nav-mobile-link',
78-
{ 'secondary-tab-nav-mobile-link--active': isItemActive(item, currentPath) },
79-
]}
80-
role="option"
81-
aria-selected={isItemActive(item, currentPath) ? 'true' : 'false'}
82-
@click="open = false"
83-
data-astro-prefetch="hover"
84-
>
85-
<span>{item.label}</span>
86-
</a>
87-
</li>
88-
))
110+
items.map((item) => {
111+
const { path: tabPath, hash: tabHash } = parseTabHref(item.href);
112+
const active = isItemActive(item, currentPath, '', items);
113+
return (
114+
<li role="presentation">
115+
<a
116+
href={item.href}
117+
class:list={[
118+
'secondary-tab-nav-mobile-link',
119+
{ 'secondary-tab-nav-mobile-link--active': active },
120+
]}
121+
role="option"
122+
aria-selected={active ? 'true' : 'false'}
123+
data-secondary-tab-path={tabPath}
124+
data-secondary-tab-hash={tabHash}
125+
data-secondary-tab-label={item.label}
126+
@click="open = false"
127+
data-astro-prefetch="hover"
128+
>
129+
<span>{item.label}</span>
130+
</a>
131+
</li>
132+
);
133+
})
89134
}
90135
</ul>
91136
</div>
92137
</div>
93138

94139
<ul class="secondary-tab-nav__list">
95140
{
96-
items.map((item) => (
97-
<li>
98-
<a
99-
href={item.href}
100-
class:list={[
101-
'secondary-tab-nav__tab',
102-
{
103-
'secondary-tab-nav__tab--active': isItemActive(item, currentPath),
104-
},
105-
]}
106-
aria-current={isItemActive(item, currentPath) ? 'page' : undefined}
107-
>
108-
{item.label}
109-
</a>
110-
</li>
111-
))
141+
items.map((item) => {
142+
const { path: tabPath, hash: tabHash } = parseTabHref(item.href);
143+
const active = isItemActive(item, currentPath, '', items);
144+
return (
145+
<li>
146+
<a
147+
href={item.href}
148+
class:list={[
149+
'secondary-tab-nav__tab',
150+
{
151+
'secondary-tab-nav__tab--active': active,
152+
},
153+
]}
154+
aria-current={active ? 'page' : undefined}
155+
data-secondary-tab-path={tabPath}
156+
data-secondary-tab-hash={tabHash}
157+
data-secondary-tab-label={item.label}
158+
>
159+
{item.label}
160+
</a>
161+
</li>
162+
);
163+
})
112164
}
113165
</ul>
114166
</nav>
167+
168+
<script>
169+
function normalizeTabPath(path: string): string {
170+
const trimmed = path.replace(/\/+$/, '');
171+
return trimmed === '' ? '/' : trimmed;
172+
}
173+
174+
function syncSecondaryTabNavHashState() {
175+
const currentPath = normalizeTabPath(window.location.pathname);
176+
const currentHash = window.location.hash.slice(1);
177+
178+
document.querySelectorAll('[data-hash-tabs]').forEach((nav) => {
179+
const links = nav.querySelectorAll<HTMLAnchorElement>('[data-secondary-tab-path]');
180+
const tabHashes = Array.from(links)
181+
.map((link) => link.dataset.secondaryTabHash ?? '')
182+
.filter((hash) => hash !== '');
183+
184+
let activeLabel = '';
185+
186+
links.forEach((link) => {
187+
const tabPath = link.dataset.secondaryTabPath ?? '';
188+
const tabHash = link.dataset.secondaryTabHash ?? '';
189+
if (tabPath !== currentPath) return;
190+
191+
const isActive = tabHash
192+
? currentHash === tabHash
193+
: tabHashes.length === 0 || !tabHashes.includes(currentHash);
194+
195+
link.classList.toggle('secondary-tab-nav__tab--active', isActive);
196+
link.classList.toggle('secondary-tab-nav-mobile-link--active', isActive);
197+
198+
if (link.classList.contains('secondary-tab-nav__tab')) {
199+
if (isActive) {
200+
link.setAttribute('aria-current', 'page');
201+
} else {
202+
link.removeAttribute('aria-current');
203+
}
204+
} else {
205+
link.setAttribute('aria-selected', isActive ? 'true' : 'false');
206+
}
207+
if (isActive) {
208+
activeLabel = link.dataset.secondaryTabLabel ?? link.textContent?.trim() ?? '';
209+
}
210+
});
211+
212+
const selected = nav.querySelector('[data-secondary-tab-mobile-selected]');
213+
if (selected && activeLabel) {
214+
selected.textContent = activeLabel;
215+
}
216+
});
217+
}
218+
219+
const globalHook = globalThis as typeof globalThis & {
220+
__secondaryTabNavHashHook?: boolean;
221+
};
222+
223+
if (!globalHook.__secondaryTabNavHashHook) {
224+
globalHook.__secondaryTabNavHashHook = true;
225+
syncSecondaryTabNavHashState();
226+
window.addEventListener('hashchange', syncSecondaryTabNavHashState);
227+
}
228+
</script>

src/components/roadmap/RoadmapCard.astro

Lines changed: 84 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ import {
66
getDaysUntilRelease,
77
getMonthAbbreviation,
88
getRoadmapCoverUrl,
9+
isRoadmapShipped,
910
} from '@libs/strapi';
11+
import { formatDate } from '@utils/dateUtils';
1012
1113
interface Props {
1214
roadmap?: StrapiRoadmap;
@@ -21,43 +23,106 @@ const slug = roadmap ? getRoadmapSlug(roadmap) : null;
2123
const hasRoadmap = Boolean(roadmap && slug);
2224
const coverUrl = roadmap ? getRoadmapCoverUrl(roadmap.cover) : undefined;
2325
const hasCover = Boolean(coverUrl);
26+
const shipped = roadmap ? isRoadmapShipped(roadmap) : false;
2427
const detailHref = slug ? `/roadmap/${slug}` : undefined;
25-
const showDetailLink = hasRoadmap && hasCover && detailHref;
28+
const showDetailLink = hasRoadmap && hasCover && detailHref && shipped;
29+
const showPreviewModal = hasRoadmap && hasCover && !shipped;
30+
const showCoverCard = showDetailLink || showPreviewModal;
2631
const daysUntil = roadmap ? getDaysUntilRelease(roadmap.releaseDate) : null;
2732
2833
const coverAlt =
2934
roadmap?.cover?.alternativeText || roadmap?.title || `${monthAbbr} roadmap release`;
3035
const detailLabel = roadmap?.title
3136
? `View ${roadmap.title} roadmap release`
3237
: `View ${monthAbbr} roadmap release`;
38+
const previewLabel = roadmap?.title
39+
? `Preview ${roadmap.title} roadmap release`
40+
: `Preview ${monthAbbr} roadmap release`;
3341
3442
// Parse "Name: Version" from title (e.g. "Mary Elizabeth: 1.1")
3543
const titleParts = roadmap?.title?.split(':') ?? [];
3644
const releaseName = titleParts[0]?.trim() ?? roadmap?.title ?? '';
3745
const releaseVersion = titleParts[1]?.trim() ?? '';
46+
const releaseMonthYear = roadmap
47+
? formatDate(roadmap.releaseDate, 'en-US', { month: 'short', year: 'numeric' })
48+
: '';
49+
50+
const previewPayload = showPreviewModal
51+
? JSON.stringify({
52+
slug,
53+
date: releaseMonthYear,
54+
name: releaseName,
55+
version: releaseVersion || undefined,
56+
})
57+
: undefined;
3858
---
3959

40-
<li class="roadmap-card">
60+
<li
61+
class="roadmap-card"
62+
data-roadmap-slug={showPreviewModal ? slug : undefined}
63+
data-roadmap-shipped={roadmap != null ? String(isRoadmapShipped(roadmap)) : undefined}
64+
>
4165
{
42-
showDetailLink ? (
66+
showCoverCard ? (
4367
<>
44-
<a href={detailHref} class="roadmap-card--image-wrap" aria-label={detailLabel}>
45-
<img
46-
src={coverUrl}
47-
alt={coverAlt}
48-
class="roadmap-card--image"
49-
loading="lazy"
50-
decoding="async"
51-
/>
52-
</a>
53-
<div class="roadmap-card--label">
54-
<a href={detailHref} class="roadmap-card--label-name">
55-
<strong>{releaseName}:</strong>
56-
{releaseVersion ? ` ${releaseVersion}` : ''}
68+
{showDetailLink ? (
69+
<a href={detailHref} class="roadmap-card--image-wrap" aria-label={detailLabel}>
70+
<img
71+
src={coverUrl}
72+
alt={coverAlt}
73+
class="roadmap-card--image"
74+
loading="lazy"
75+
decoding="async"
76+
/>
5777
</a>
58-
<time datetime={roadmap!.releaseDate} class="roadmap-card--label-date">
59-
{monthAbbr.slice(0, 3).charAt(0) + monthAbbr.slice(0, 3).slice(1).toLowerCase()}
60-
</time>
78+
) : (
79+
<button
80+
type="button"
81+
class="roadmap-card--image-wrap roadmap-card--preview-trigger"
82+
data-roadmap-empty-open={previewPayload}
83+
aria-label={previewLabel}
84+
>
85+
<img
86+
src={coverUrl}
87+
alt={coverAlt}
88+
class="roadmap-card--image"
89+
loading="lazy"
90+
decoding="async"
91+
/>
92+
</button>
93+
)}
94+
<div class="roadmap-card--label">
95+
{showDetailLink ? (
96+
<>
97+
<time datetime={roadmap!.releaseDate} class="roadmap-card--label-date">
98+
{releaseMonthYear}
99+
</time>
100+
<a href={detailHref} class="roadmap-card--label-name">
101+
<strong>{releaseName}:</strong>
102+
{releaseVersion ? ` ${releaseVersion}` : ''}
103+
</a>
104+
</>
105+
) : (
106+
<>
107+
<button
108+
type="button"
109+
class="roadmap-card--label-date roadmap-card--preview-trigger"
110+
data-roadmap-empty-open={previewPayload}
111+
aria-label={previewLabel}
112+
>
113+
{releaseMonthYear}
114+
</button>
115+
<button
116+
type="button"
117+
class="roadmap-card--label-name roadmap-card--preview-trigger"
118+
data-roadmap-empty-open={previewPayload}
119+
aria-label={previewLabel}
120+
>
121+
<strong>{releaseName}:</strong>
122+
{releaseVersion ? ` ${releaseVersion}` : ''}
123+
</button>
124+
</>
125+
)}
61126
</div>
62127
</>
63128
) : (

0 commit comments

Comments
 (0)