feat(i18n): reconcile multilingual navigation and RTL rendering

This commit is contained in:
DeCentN2Madness committed 2026-10-10 20:15:25 -07:00
1 parent 8192ea71c4
commit a699d467f1
15 files changed
+806 -232

No files matched your search

+35 -16
View File
@@ -1,46 +1,62 @@
---
import ContactObfuscation from './ContactObfuscation.astro';
import site from '../data/site.json';
import { fallbackLabel, resolveNavigationDestination, type VisitorLocale, type VisitorPublication } from '../utils/visitor-navigation';
const year = new Date().getFullYear();
const { lang = 'en' } = Astro.props;
interface Props { lang?: VisitorLocale; manifest: VisitorPublication[]; }
const { lang = 'en', manifest } = Astro.props;
const labels = lang === 'es'
? { about: 'Información', overview: 'Acerca de AIA', team: 'Equipo', careers: 'Carreras', giveback: 'Devolver', resources: 'Recursos', forms: 'Formularios', insurance: 'Seguros', referrals: 'Referencias', employee: 'Portal para empleados', contact: 'Contáctanos' }
: { about: 'About', overview: 'Overview', team: 'Team', careers: 'Careers', giveback: 'Giveback', resources: 'Resources', forms: 'Forms', insurance: 'Insurance', referrals: 'Referrals', employee: 'Employee Portal', contact: 'Get In Touch' };
const resolve = (label: string, href: string) => {
const destination = resolveNavigationDestination(href, lang, manifest);
return destination ? { label, ...destination } : null;
};
const aboutLinks = [
resolve(labels.overview, '/about'), resolve(labels.team, '/team'),
resolve(labels.careers, '/careers'), resolve(labels.giveback, '/donate-autism-giveback')
].filter((item) => item !== null);
const resourceLinks = [
resolve(labels.forms, '/client-forms'), resolve(labels.insurance, '/insurance'),
resolve(labels.referrals, '/referrals'), resolve(labels.employee, '/employee-portal')
].filter((item) => item !== null);
const privacy = resolve('Privacy Policy', '/privacy-policy');
const faq = resolve('FAQ', '/faqs');
const sourceLanguage = lang === 'ar' ? 'en' : undefined;
---
<footer class="site-footer">
<div class="container footer-grid">
<div>
<div lang={sourceLanguage}>
<h2>{labels.about}</h2>
<ul>
<li><a href="/about">{labels.overview}</a></li>
<li><a href="/team">{labels.team}</a></li>
<li><a href="/careers">{labels.careers}</a></li>
<li><a href="/donate-autism-giveback">{labels.giveback}</a></li>
{aboutLinks.map((item) => <li><a href={item.href} hreflang={item.fallback ? item.lang : undefined} aria-label={item.fallback ? `${item.label} — ${fallbackLabel(item.lang)}` : undefined}>{item.label}{item.fallback && <bdi class="footer-fallback" lang={item.lang}>{fallbackLabel(item.lang)}</bdi>}</a></li>)}
</ul>
</div>
<div>
<div lang={sourceLanguage}>
<h2>{labels.resources}</h2>
<ul>
<li><a href="/client-forms">{labels.forms}</a></li>
<li><a href="/insurance">{labels.insurance}</a></li>
<li><a href="/referrals">{labels.referrals}</a></li>
<li><a href="/employee-portal">{labels.employee}</a></li>
{resourceLinks.map((item) => <li><a href={item.href} hreflang={item.fallback ? item.lang : undefined} aria-label={item.fallback ? `${item.label} — ${fallbackLabel(item.lang)}` : undefined}>{item.label}{item.fallback && <bdi class="footer-fallback" lang={item.lang}>{fallbackLabel(item.lang)}</bdi>}</a></li>)}
</ul>
</div>
<div>
<div lang={sourceLanguage}>
<h2>{labels.contact}</h2>
<div class="footer-contact">
<div class="footer-contact-item">
<img src="/assets/images/phone-blue.svg" alt="" aria-hidden="true" width="24" height="24" />
<ContactObfuscation phone={site.phone} hrefValue="+14806877099" obfuscatedText="9907-786 (084)" />
<bdi class="footer-contact-value" dir="ltr"><ContactObfuscation phone={site.phone} hrefValue="+14806877099" obfuscatedText="9907-786 (084)" /></bdi>
</div>
<div class="footer-contact-item">
<img src="/assets/images/envelope-blue.svg" alt="" aria-hidden="true" width="24" height="24" />
<ContactObfuscation email={site.email} obfuscatedText="moc.msitua4etutitsniza@ofni" />
<bdi class="footer-contact-value" dir="ltr"><ContactObfuscation email={site.email} obfuscatedText="moc.msitua4etutitsniza@ofni" /></bdi>
</div>
<div class="footer-contact-item">
<img src="/assets/images/location-blue.svg" alt="" aria-hidden="true" width="24" height="24" />
<address>{site.address}</address>
<address class="footer-contact-value" dir="ltr">
<bdi dir="ltr">
{site.address.line1}<br />
{site.address.line2}
</bdi>
</address>
</div>
</div>
<div class="social-links">
@@ -61,7 +77,7 @@ const labels = lang === 'es'
</div>
<div class="container footer-bottom">
<p>© 2018-{year} Arizona Institute for Autism ♥ All Rights Reserved</p>
<p><a href="/privacy-policy">Privacy Policy</a> | <a href="/faqs">FAQ</a></p>
<p lang={sourceLanguage}>{privacy && <a href={privacy.href} hreflang={privacy.fallback ? privacy.lang : undefined} aria-label={privacy.fallback ? `Privacy Policy — ${fallbackLabel(privacy.lang)}` : undefined}>Privacy Policy{privacy.fallback && <bdi class="footer-fallback" lang={privacy.lang}>{fallbackLabel(privacy.lang)}</bdi>}</a>} | {faq && <a href={faq.href} hreflang={faq.fallback ? faq.lang : undefined} aria-label={faq.fallback ? `FAQ — ${fallbackLabel(faq.lang)}` : undefined}>FAQ{faq.fallback && <bdi class="footer-fallback" lang={faq.lang}>{fallbackLabel(faq.lang)}</bdi>}</a>}</p>
</div>
</footer>
@@ -71,6 +87,8 @@ const labels = lang === 'es'
.site-footer h2, .site-footer a { color: white; text-decoration: none; }
.site-footer h2 { font-size: 1.25rem; letter-spacing: .3px; margin-bottom: 1.2rem; }
.site-footer a:hover { color: var(--color-accent); }
.site-footer a:focus-visible { outline: 3px solid var(--color-accent); outline-offset: 3px; }
.footer-fallback { border: 1px solid currentColor; border-radius: 2px; font-size: .58rem; line-height: 1; margin-inline-start: .35rem; padding: .1rem .2rem; text-transform: uppercase; }
.site-footer ul { list-style: none; padding: 0; }
.site-footer li { line-height: 2.6; margin-bottom: 0; }
.site-footer p { line-height: 2.6; }
@@ -78,6 +96,7 @@ const labels = lang === 'es'
.footer-contact-item { align-items: flex-start; display: flex; gap: .6rem; line-height: 1.5; }
.footer-contact-item img { flex-shrink: 0; }
.footer-contact-item address { font-style: normal; }
.footer-contact-value { direction: ltr; text-align: left; unicode-bidi: isolate; }
.social-links { display: flex; gap: .85rem; margin-left: 1.6rem; margin-top: .25rem; }
.social-links a { align-items: center; color: white; display: flex; opacity: .85; }
.social-links a:hover { color: var(--color-accent); opacity: 1; }
+12 -10
View File
@@ -3,10 +3,12 @@ import Button from './Button.astro';
import LanguageSwitcher from './LanguageSwitcher.astro';
import MainNav from './MainNav.astro';
import MobileNav from './MobileNav.astro';
import { getPublicationManifest } from '../utils/publication';
const { lang = 'en' } = Astro.props;
const localizedHome = lang === 'en' ? '/' : `/${lang}`;
const homeHref = (await getPublicationManifest()).some((page) => page.eligible && page.route === localizedHome) ? localizedHome : '/';
import { fallbackLabel, resolveNavigationDestination, type VisitorLocale, type VisitorPublication } from '../utils/visitor-navigation';
interface Props { lang?: VisitorLocale; publication: VisitorPublication; manifest: VisitorPublication[]; }
const { lang = 'en', publication, manifest } = Astro.props;
const homeTarget = resolveNavigationDestination(lang === 'en' ? '/' : `/${lang}`, lang, manifest)
?? resolveNavigationDestination('/', lang, manifest);
const tourTarget = resolveNavigationDestination('/tour', lang, manifest);
const utility = lang === 'es'
? { credit: 'Solicitar CareCredit', bill: 'Pagar mi factura' }
: { credit: 'Apply Care Credit', bill: 'Pay My Bill' };
@@ -19,13 +21,13 @@ const utility = lang === 'es'
</div>
</div>
<div class="container header-inner">
<a class="brand" href={homeHref}>
{homeTarget ? <a class="brand" href={homeTarget.href} lang={homeTarget.fallback ? homeTarget.lang : undefined} hreflang={homeTarget.fallback ? homeTarget.lang : undefined}>
<img src="/assets/images/aia-logo.svg" alt="Arizona Institute for Autism" width="150" height="70" />
</a>
<MainNav lang={lang} />
<MobileNav lang={lang} />
<LanguageSwitcher />
<Button href="/tour" size="small">Tour AIA</Button>
</a> : <span class="brand"><img src="/assets/images/aia-logo.svg" alt="Arizona Institute for Autism" width="150" height="70" /></span>}
<MainNav lang={lang} manifest={manifest} />
<MobileNav lang={lang} manifest={manifest} />
<LanguageSwitcher publication={publication} manifest={manifest} />
{tourTarget && <Button href={tourTarget.href} size="small">Tour AIA{tourTarget.fallback && ` (${fallbackLabel(tourTarget.lang)})`}</Button>}
</div>
</header>
+49 -37
View File
@@ -1,45 +1,57 @@
---
import { getPublication } from '../utils/publication';
const publication = await getPublication(Astro.url.pathname);
import { visitorLanguageChoices, type VisitorPublication } from '../utils/visitor-navigation';
interface Props { publication: VisitorPublication; manifest: VisitorPublication[]; }
const { publication, manifest } = Astro.props;
const pathname = publication.route;
const langMeta: Record<string, { short: string; full: string }> = {
en: { short: 'EN', full: 'English' },
es: { short: 'ES', full: 'Español' },
ar: { short: 'ع', full: 'عربي' },
};
const choices = publication.translations.map(({ lang, route }) => ({ code: lang, href: route }));
const activeCode = publication.entry.data.lang;
const activeShort = langMeta[activeCode]?.short ?? 'EN';
const langMeta = {
en: { short: 'EN', full: 'English', navigation: 'Language', section: 'section home' },
es: { short: 'ES', full: 'Español', navigation: 'Idioma', section: 'inicio de la sección' },
ar: { short: 'ع', full: 'العربية', navigation: 'اللغة', section: 'الصفحة الرئيسية للقسم' }
} as const;
const choices = visitorLanguageChoices(publication, manifest);
const activeCode = publication.entry.data.lang as keyof typeof langMeta;
const active = langMeta[activeCode] ?? langMeta.en;
const hasAlternatives = choices.some(({ route }) => route !== pathname);
---
<nav class="lang-switcher" aria-label="Language">
<div class="lang-switcher__trigger" tabindex={choices.length ? 0 : undefined} aria-haspopup={choices.length ? 'listbox' : undefined} aria-label={`Language: ${activeCode.toUpperCase()}`}>
<span class="lang-switcher__label">{activeShort}</span>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
<path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8zm7.5-6.923c-.67.204-1.335.82-1.887 1.855A7.97 7.97 0 0 0 5.145 4H7.5V1.077zM4.09 4a9.267 9.267 0 0 1 .64-1.539 6.7 6.7 0 0 1 .597-.933A7.025 7.025 0 0 0 2.255 4H4.09zm-.582 3.5c.03-.877.138-1.718.312-2.5H1.674a6.958 6.958 0 0 0-.656 2.5h2.49zM4.847 5a12.5 12.5 0 0 0-.338 2.5H7.5V5H4.847zM8.5 5v2.5h2.99a12.495 12.495 0 0 0-.337-2.5H8.5zM4.51 8.5a12.5 12.5 0 0 0 .337 2.5H7.5V8.5H4.51zm3.99 0V11h2.653c.187-.765.306-1.608.338-2.5H8.5zM5.145 12c.138.386.295.744.468 1.068.552 1.035 1.218 1.65 1.887 1.855V12H5.145zm.182 2.472a6.696 6.696 0 0 1-.597-.933A9.268 9.268 0 0 1 4.09 12H2.255a7.024 7.024 0 0 0 3.072 2.472zM3.82 11a13.652 13.652 0 0 1-.312-2.5h-2.49c.062.89.291 1.733.656 2.5H3.82zm6.853 3.472A7.024 7.024 0 0 0 13.745 12H11.91a9.27 9.27 0 0 1-.64 1.539 6.688 6.688 0 0 1-.597.933zM8.5 12v2.923c.67-.204 1.335-.82 1.887-1.855.173-.324.33-.682.468-1.068H8.5zm3.68-1h2.146c.365-.767.594-1.61.656-2.5h-2.49a13.65 13.65 0 0 1-.312 2.5zm2.802-3.5a6.959 6.959 0 0 0-.656-2.5H12.18c.174.782.282 1.623.312 2.5h2.49zM11.27 2.461c.247.464.462.98.64 1.539h1.835a7.024 7.024 0 0 0-3.072-2.472c.218.284.418.598.597.933zM10.855 4a7.966 7.966 0 0 0-.468-1.068C9.835 1.897 9.17 1.282 8.5 1.077V4h2.355z" />
</svg>
</div>
{choices.length > 0 && <ul class="lang-switcher__menu" role="listbox">
{choices.map(({ code, href }) => (
<li>
<a href={href} lang={code} aria-current={pathname === href || (href === '/' && pathname === '/') ? 'page' : undefined}>
{langMeta[code]?.full}
</a>
</li>
))}
</ul>}
<nav class="lang-switcher" aria-label={active.navigation}>
{hasAlternatives ? (
<details class="lang-switcher__details">
<summary aria-label={`${active.navigation}: ${active.full}`}>
<span class="lang-switcher__label">{active.short}</span>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
<path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8zm7.5-6.923c-.67.204-1.335.82-1.887 1.855A7.97 7.97 0 0 0 5.145 4H7.5V1.077zM4.09 4a9.267 9.267 0 0 1 .64-1.539 6.7 6.7 0 0 1 .597-.933A7.025 7.025 0 0 0 2.255 4H4.09zm-.582 3.5c.03-.877.138-1.718.312-2.5H1.674a6.958 6.958 0 0 0-.656 2.5h2.49zM4.847 5a12.5 12.5 0 0 0-.338 2.5H7.5V5H4.847zM8.5 5v2.5h2.99a12.495 12.495 0 0 0-.337-2.5H8.5zM4.51 8.5a12.5 12.5 0 0 0 .337 2.5H7.5V8.5H4.51zm3.99 0V11h2.653c.187-.765.306-1.608.338-2.5H8.5zM5.145 12c.138.386.295.744.468 1.068.552 1.035 1.218 1.65 1.887 1.855V12H5.145zm.182 2.472a6.696 6.696 0 0 1-.597-.933A9.268 9.268 0 0 1 4.09 12H2.255a7.024 7.024 0 0 0 3.072 2.472zM3.82 11a13.652 13.652 0 0 1-.312-2.5h-2.49c.062.89.291 1.733.656 2.5H3.82zm6.853 3.472A7.024 7.024 0 0 0 13.745 12H11.91a9.27 9.27 0 0 1-.64 1.539 6.688 6.688 0 0 1-.597.933zM8.5 12v2.923c.67-.204 1.335-.82 1.887-1.855.173-.324.33-.682.468-1.068H8.5zm3.68-1h2.146c.365-.767.594-1.61.656-2.5h-2.49a13.65 13.65 0 0 1-.312 2.5zm2.802-3.5a6.959 6.959 0 0 0-.656-2.5H12.18c.174.782.282 1.623.312 2.5h2.49zM11.27 2.461c.247.464.462.98.64 1.539h1.835a7.024 7.024 0 0 0-3.072-2.472c.218.284.418.598.597.933zM10.855 4a7.966 7.966 0 0 0-.468-1.068C9.835 1.897 9.17 1.282 8.5 1.077V4h2.355z" />
</svg>
</summary>
<ul class="lang-switcher__menu">
{choices.map((choice) => {
const meta = langMeta[choice.lang];
return <li><a href={choice.route} lang={choice.lang} hreflang={choice.lang} aria-current={choice.current ? (choice.route === pathname ? 'page' : 'true') : undefined}><span>{meta.full}</span>{choice.kind === 'section-root' && <small lang={activeCode}>{active.section}</small>}</a></li>;
})}
</ul>
</details>
) : <span class="lang-switcher__static" aria-label={`${active.navigation}: ${active.full}`}><span class="lang-switcher__label">{active.short}</span></span>}
</nav>
<script>
document.querySelectorAll<HTMLDetailsElement>('.lang-switcher__details').forEach((details) => {
details.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && details.open) {
details.open = false;
details.querySelector<HTMLElement>('summary')?.focus();
}
});
});
</script>
<style>
.lang-switcher { position: relative; }
.lang-switcher__trigger { align-items: center; color: var(--color-primary); cursor: pointer; display: flex; font-size: .85rem; font-weight: 700; gap: .35rem; }
.lang-switcher__trigger svg { flex-shrink: 0; }
.lang-switcher__menu { background: white; box-shadow: var(--shadow-md); display: none; left: 50%; list-style: none; margin: 0; min-width: 110px; padding: .5rem 0 .4rem; position: absolute; top: 100%; transform: translateX(-50%); z-index: 10; }
.lang-switcher__menu a { color: rgb(83 83 91); display: block; font-size: .85rem; padding: .4rem .9rem; text-decoration: none; white-space: nowrap; }
.lang-switcher summary, .lang-switcher__static { align-items: center; color: var(--color-primary); display: flex; font-size: .85rem; font-weight: 700; gap: .35rem; min-height: 44px; }
.lang-switcher summary { cursor: pointer; list-style: none; }
.lang-switcher summary::-webkit-details-marker { display: none; }
.lang-switcher summary:focus-visible, .lang-switcher__menu a:focus-visible { outline: 3px solid var(--color-accent); outline-offset: 3px; }
.lang-switcher summary svg { flex-shrink: 0; }
.lang-switcher__menu { background: white; box-shadow: var(--shadow-md); inset-inline-start: 50%; list-style: none; margin: 0; min-width: 150px; padding: .5rem 0 .4rem; position: absolute; top: 100%; transform: translateX(-50%); z-index: 10; }
:global(html[dir="rtl"]) .lang-switcher__menu { transform: translateX(50%); }
.lang-switcher__menu a { color: rgb(83 83 91); display: flex; flex-direction: column; font-size: .85rem; padding: .45rem .9rem; text-decoration: none; white-space: nowrap; }
.lang-switcher__menu a:hover { background: var(--color-primary); color: white; }
.lang-switcher__menu a[aria-current="page"] { color: var(--color-accent); font-weight: 700; }
.lang-switcher:hover .lang-switcher__menu,
.lang-switcher:focus-within .lang-switcher__menu { display: block; }
.lang-switcher__menu a[aria-current="page"] { color: var(--color-accent-strong); font-weight: 700; }
.lang-switcher__menu small { font-size: .68rem; font-weight: 400; }
</style>
+25 -11
View File
@@ -1,37 +1,51 @@
---
import en from '../data/navigation.en.json';
import es from '../data/navigation.es.json';
const { lang = 'en' } = Astro.props;
const nav = { en, es, ar: en }[lang as 'en' | 'es' | 'ar'] || en;
import ar from '../data/navigation.ar.json';
import { fallbackLabel, resolveNavigationDestination, type VisitorLocale, type VisitorPublication } from '../utils/visitor-navigation';
interface Props { lang?: VisitorLocale; manifest: VisitorPublication[]; }
const { lang = 'en', manifest } = Astro.props;
const nav = { en, es, ar }[lang];
const aboutLinks = lang === 'es'
? [['Equipo', '/team'], ['Carreras', '/careers'], ['Preguntas frecuentes', '/faqs']]
: [['Team', '/team'], ['Careers', '/careers'], ['FAQ', '/faqs']];
const resolve = (item: { label: string; href: string }) => {
const destination = resolveNavigationDestination(item.href, lang, manifest);
return destination ? { ...item, ...destination } : null;
};
const primary = nav.primary.map(resolve).filter((item) => item !== null);
const services = nav.services.map(resolve).filter((item) => item !== null);
const about = aboutLinks.map(([label, href]) => resolve({ label, href })).filter((item) => item !== null);
const navLabel = { en: 'Main navigation', es: 'Navegación principal', ar: 'التنقل الرئيسي' }[lang];
---
<nav class="main-nav" aria-label="Main navigation">
<nav class="main-nav" aria-label={navLabel}>
<ul>
{nav.primary.map((item) => (
{primary.map((item) => (
<li class:list={{ 'has-submenu': item.href.endsWith('/about') || item.href.endsWith('/services') }}>
<a href={item.href}>{item.label}</a>
{item.href.endsWith('/about') && <ul class="submenu">
{aboutLinks.map(([label, href]) => <li><a href={href}>{label}</a></li>)}
<a href={item.href} hreflang={item.fallback ? item.lang : undefined} aria-label={item.fallback ? `${item.label} — ${fallbackLabel(item.lang)}` : undefined}>
{item.label}{item.fallback && <bdi class="nav-fallback" lang={item.lang}>{fallbackLabel(item.lang)}</bdi>}
</a>
{item.href.endsWith('/about') && about.length > 0 && <ul class="submenu">
{about.map((link) => <li><a href={link.href} hreflang={link.fallback ? link.lang : undefined} aria-label={link.fallback ? `${link.label} — ${fallbackLabel(link.lang)}` : undefined}>{link.label}{link.fallback && <bdi class="nav-fallback" lang={link.lang}>{fallbackLabel(link.lang)}</bdi>}</a></li>)}
</ul>}
{item.href.endsWith('/services') && <ul class="submenu">
{nav.services.map((service) => <li><a href={service.href}>{service.label}</a></li>)}
{item.href.endsWith('/services') && services.length > 0 && <ul class="submenu">
{services.map((service) => <li><a href={service.href} hreflang={service.fallback ? service.lang : undefined} aria-label={service.fallback ? `${service.label} — ${fallbackLabel(service.lang)}` : undefined}>{service.label}{service.fallback && <bdi class="nav-fallback" lang={service.lang}>{fallbackLabel(service.lang)}</bdi>}</a></li>)}
</ul>}
</li>
))}
</ul>
</nav>
<style>
.main-nav { margin-inline: auto; }
.main-nav ul { display: flex; gap: 1.4rem; list-style: none; margin: 0; padding: 0; }
.main-nav li { position: relative; }
.main-nav a { color: var(--color-primary); font-size: .9rem; text-decoration: none; }
.main-nav a:hover { color: var(--color-accent-strong); }
.main-nav .submenu { background: white; box-shadow: var(--shadow-md); display: none; gap: 0; left: -1rem; min-width: 180px; padding: .75rem 0; position: absolute; top: 100%; }
.nav-fallback { border: 1px solid currentColor; border-radius: 2px; font-size: .58rem; line-height: 1; margin-inline-start: .35rem; padding: .1rem .2rem; text-transform: uppercase; }
.main-nav .submenu { background: white; box-shadow: var(--shadow-md); display: none; gap: 0; inset-inline-start: -1rem; min-width: 180px; padding: .75rem 0; position: absolute; top: 100%; }
.main-nav .submenu a { display: block; padding: .45rem 1rem; }
.main-nav li:hover > .submenu, .main-nav li:focus-within > .submenu { display: block; }
.main-nav a:focus-visible { outline: 3px solid var(--color-accent); outline-offset: 3px; }
@media (max-width: 950px) {
.main-nav { display: none; }
:global(.mobile-nav) .main-nav { display: block; }
+18 -3
View File
@@ -1,14 +1,29 @@
---
import MainNav from './MainNav.astro';
const { lang = 'en' } = Astro.props;
import type { VisitorLocale, VisitorPublication } from '../utils/visitor-navigation';
interface Props { lang?: VisitorLocale; manifest: VisitorPublication[]; }
const { lang = 'en', manifest } = Astro.props;
const menu = { en: 'Menu', es: 'Menú', ar: 'القائمة' }[lang];
---
<details class="mobile-nav">
<summary>Menu</summary>
<MainNav lang={lang} />
<summary>{menu}</summary>
<MainNav lang={lang} manifest={manifest} />
</details>
<script>
document.querySelectorAll<HTMLDetailsElement>('details.mobile-nav').forEach((details) => {
details.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && details.open) {
details.open = false;
details.querySelector<HTMLElement>('summary')?.focus();
}
});
});
</script>
<style>
.mobile-nav { display: none; }
.mobile-nav summary:focus-visible { outline: 3px solid var(--color-accent); outline-offset: 3px; }
@media (max-width: 950px) {
.mobile-nav { display: block; }
}
+4 -1
View File
@@ -4,7 +4,10 @@
"url": "https://www.azinstitute4autism.com",
"phone": "(480) 687-7099",
"email": "info@azinstitute4autism.com",
"address": "8901 E Raintree Dr Ste 160, Scottsdale, AZ 85260",
"address": {
"line1": "8901 E Raintree Dr Ste 160,",
"line2": "Scottsdale, AZ 85260"
},
"social": {
"facebook": "https://www.facebook.com/Arizona-Institute-for-Autism-114880900797124/",
"instagram": "https://www.instagram.com/azinstitute4autism/",
+7 -4
View File
@@ -3,16 +3,19 @@ import Header from '../components/Header.astro';
import Footer from '../components/Footer.astro';
import Seo from '../components/Seo.astro';
import site from '../data/site.json';
import { getPublication } from '../utils/publication';
import { getPublication, getRoutePublicationManifest } from '../utils/publication';
import type { VisitorLocale, VisitorPublication } from '../utils/visitor-navigation';
import '../styles/global.css';
import '../styles/rtl.css';
const { title, description, image, lang = 'en', type = 'website' } = Astro.props;
const publication = await getPublication(Astro.url.pathname);
const manifest = await getRoutePublicationManifest() as VisitorPublication[];
// Reuse alt text only for the image it describes, not a fallback banner image.
const imageAlt = image && image === publication.entry.data.featuredImage
? publication.entry.data.alt
: undefined;
const dir = lang === 'ar' ? 'rtl' : 'ltr';
const skipLink = { en: 'Skip to content', es: 'Saltar al contenido', ar: 'انتقل إلى المحتوى' }[lang as VisitorLocale] ?? 'Skip to content';
const organizationSchema = {
'@context': 'https://schema.org',
'@type': 'MedicalOrganization',
@@ -41,9 +44,9 @@ const organizationSchema = {
<script type="application/ld+json" is:inline set:html={JSON.stringify(organizationSchema)} />
</head>
<body>
<a class="skip-link" href="#main">Skip to content</a>
<Header lang={lang} />
<a class="skip-link" href="#main">{skipLink}</a>
<Header lang={lang as VisitorLocale} publication={publication as VisitorPublication} manifest={manifest} />
<main id="main"><slot /></main>
<Footer lang={lang} />
<Footer lang={lang as VisitorLocale} manifest={manifest} />
</body>
</html>
+1
View File
@@ -11,6 +11,7 @@ export interface AuthorEntry {
displayName?: string;
description?: string;
avatar?: string;
translationKey?: string;
};
}
+148
View File
@@ -0,0 +1,148 @@
import { normalizeRoute, type PublicationEntry, type TranslatedPublication } from './publication-policy.ts';
export type VisitorLocale = 'en' | 'es' | 'ar';
export interface VisitorPublication extends TranslatedPublication {
entry: PublicationEntry & {
data: PublicationEntry['data'] & { translationKey?: string };
};
libraryPage?: { locale: VisitorLocale; currentPage: number };
authorPage?: {
locale: VisitorLocale;
currentPage: number;
author: {
name: string;
archiveRoute: string;
entry: { data: { lang: string; translationKey?: string } };
};
};
}
export interface NavigationDestination {
href: string;
lang: VisitorLocale;
fallback: boolean;
source: 'exact' | 'translation' | 'explicit-fallback';
}
export interface VisitorLanguageChoice {
lang: VisitorLocale;
route: string;
current: boolean;
kind: 'equivalent' | 'section-root';
}
const locales: VisitorLocale[] = ['en', 'es', 'ar'];
const explicitFallbacks = new Map<string, string>([
// The Arabic homepage is an unpublished placeholder. Keep the visible Arabic
// Home item, but send it to the published English root and identify the fallback.
['ar:/ar', '/']
]);
const isLocale = (value: string): value is VisitorLocale => locales.includes(value as VisitorLocale);
const publicationLocale = (publication: VisitorPublication): VisitorLocale => {
const locale = publication.entry.data.lang;
if (!isLocale(locale)) throw new Error(`Invalid publication locale for visitor navigation: ${publication.route}`);
return locale;
};
function eligibleRoute(route: string, manifest: readonly VisitorPublication[]) {
return manifest.find((publication) => publication.eligible && publication.route === normalizeRoute(route));
}
/** Resolve one menu target without guessing locale prefixes or relying on SEO output. */
export function resolveNavigationDestination(
requestedRoute: string,
locale: VisitorLocale,
manifest: readonly VisitorPublication[]
): NavigationDestination | null {
if (/^(?:[a-z]+:)?\/\//i.test(requestedRoute)) return null;
const route = normalizeRoute(requestedRoute);
const direct = eligibleRoute(route, manifest);
if (direct && publicationLocale(direct) === locale) {
return { href: direct.route, lang: locale, fallback: false, source: 'exact' };
}
if (direct) {
const translated = direct.translations.find((candidate) => candidate.lang === locale);
const destination = translated && eligibleRoute(translated.route, manifest);
if (destination && publicationLocale(destination) === locale) {
return { href: destination.route, lang: locale, fallback: false, source: 'translation' };
}
}
const fallbackRoute = explicitFallbacks.get(`${locale}:${route}`);
const fallback = eligibleRoute(fallbackRoute ?? direct?.route ?? route, manifest);
if (!fallback) return null;
return {
href: fallback.route,
lang: publicationLocale(fallback),
fallback: publicationLocale(fallback) !== locale,
source: 'explicit-fallback'
};
}
function validateChoices(choices: VisitorLanguageChoice[]) {
const seen = new Set<string>();
for (const choice of choices) {
if (seen.has(choice.lang)) throw new Error(`Ambiguous visitor language destination: ${choice.lang}`);
seen.add(choice.lang);
}
return choices.sort((left, right) => locales.indexOf(left.lang) - locales.indexOf(right.lang));
}
/**
* Visitor choices are deliberately separate from hreflang. Content routes use
* the validated translation graph; archive pagination may navigate to a known
* section root without claiming page-number equivalence.
*/
export function visitorLanguageChoices(
current: VisitorPublication,
manifest: readonly VisitorPublication[]
): VisitorLanguageChoice[] {
const currentLocale = publicationLocale(current);
if (current.authorPage) {
const key = current.authorPage.author.entry.data.translationKey;
if (typeof key !== 'string' || !key.trim()) return [];
const roots = manifest.filter((publication) => publication.eligible && publication.authorPage?.currentPage === 1
&& publication.authorPage.author.entry.data.translationKey === key);
const identity = current.authorPage.author.name;
for (const root of roots) {
if (root.authorPage?.author.name !== identity) {
throw new Error(`Author translation identity mismatch: ${key}`);
}
}
return validateChoices(roots.map((root) => ({
lang: publicationLocale(root), route: root.route,
current: publicationLocale(root) === currentLocale,
kind: current.authorPage!.currentPage === 1 ? 'equivalent' : 'section-root'
})));
}
if (current.libraryPage) {
const roots = locales.flatMap((locale) => {
const route = locale === 'en' ? '/library' : `/${locale}/library`;
const root = eligibleRoute(route, manifest);
return root ? [root] : [];
});
return validateChoices(roots.map((root) => ({
lang: publicationLocale(root), route: root.route,
current: publicationLocale(root) === currentLocale,
kind: 'section-root'
})));
}
return validateChoices(current.translations.flatMap((translation) => {
const target = eligibleRoute(translation.route, manifest);
if (!target || publicationLocale(target) !== translation.lang) return [];
return [{
lang: translation.lang as VisitorLocale, route: target.route,
current: target.route === current.route, kind: 'equivalent' as const
}];
}));
}
export function fallbackLabel(locale: VisitorLocale) {
return { en: 'English', es: 'Español', ar: 'العربية' }[locale];
}