feat: add Header with language switcher and theme toggle

Also disables Vitest file parallelism since concurrent astro builds
across test files raced on the shared dist/ output.
This commit is contained in:
2026-08-02 15:57:35 +02:00
parent 1d65bb7aa8
commit 86049dd8b5
8 changed files with 119 additions and 0 deletions
@@ -468,6 +468,8 @@ describe('BaseLayout SEO tags and theming', () => {
Note: from this task on, tests call `npm run build` themselves via `beforeAll`, so `npm run test` alone is sufficient going forward. Astro inlines small stylesheets into a `<style>` tag during build rather than emitting an external `<link>` — the test accepts either form. Note: from this task on, tests call `npm run build` themselves via `beforeAll`, so `npm run test` alone is sufficient going forward. Astro inlines small stylesheets into a `<style>` tag during build rather than emitting an external `<link>` — the test accepts either form.
Once a second build-triggering test file exists (Task 4 onward), Vitest's default parallel file execution races multiple concurrent `astro build` runs against the same `dist/` directory. Add `vitest.config.ts` at the repo root with `test: { fileParallelism: false }` to force test files to run sequentially.
- [ ] **Step 2: Run the test and confirm it fails** - [ ] **Step 2: Run the test and confirm it fails**
Run: `npm run test -- tests/seo-theme.test.ts` Run: `npm run test -- tests/seo-theme.test.ts`
+20
View File
@@ -0,0 +1,20 @@
---
import LanguageSwitcher from './LanguageSwitcher.astro';
import ThemeToggle from './ThemeToggle.astro';
import { getDictionary } from '../i18n';
import type { Locale } from '../i18n/locale';
interface Props {
locale: Locale;
}
const { locale } = Astro.props;
const t = getDictionary(locale);
---
<header class="site-header">
<a class="brand" href={`/${locale}/`}>{t.nav.brand}</a>
<div class="header-actions">
<LanguageSwitcher locale={locale} />
<ThemeToggle locale={locale} />
</div>
</header>
+14
View File
@@ -0,0 +1,14 @@
---
import { otherLocale, swapLocalePath } from '../i18n/locale';
import type { Locale } from '../i18n/locale';
interface Props {
locale: Locale;
}
const { locale } = Astro.props;
const target = otherLocale(locale);
const href = swapLocalePath(Astro.url.pathname, locale, target);
const label = target === 'en' ? 'English' : 'Français';
---
<a class="language-switcher" href={href} hreflang={target}>{label}</a>
+37
View File
@@ -0,0 +1,37 @@
---
import { getDictionary } from '../i18n';
import type { Locale } from '../i18n/locale';
interface Props {
locale: Locale;
}
const { locale } = Astro.props;
const t = getDictionary(locale);
---
<button
type="button"
class="theme-toggle"
data-label-to-dark={t.theme.toggleToDark}
data-label-to-light={t.theme.toggleToLight}
aria-label={t.theme.toggleToDark}
>
🌙
</button>
<script is:inline>
(function () {
document.querySelectorAll('.theme-toggle').forEach(function (btn) {
btn.addEventListener('click', function () {
var html = document.documentElement;
var current = html.getAttribute('data-theme');
var next = current === 'dark' ? 'light' : 'dark';
html.setAttribute('data-theme', next);
localStorage.setItem('ombrora-theme', next);
btn.setAttribute(
'aria-label',
next === 'dark' ? btn.dataset.labelToDark : btn.dataset.labelToLight
);
});
});
})();
</script>
+2
View File
@@ -1,8 +1,10 @@
--- ---
import BaseLayout from '../../layouts/BaseLayout.astro'; import BaseLayout from '../../layouts/BaseLayout.astro';
import Header from '../../components/Header.astro';
const locale = 'en'; const locale = 'en';
--- ---
<BaseLayout locale={locale}> <BaseLayout locale={locale}>
<Header locale={locale} />
<h1>Ombrora</h1> <h1>Ombrora</h1>
</BaseLayout> </BaseLayout>
+2
View File
@@ -1,8 +1,10 @@
--- ---
import BaseLayout from '../../layouts/BaseLayout.astro'; import BaseLayout from '../../layouts/BaseLayout.astro';
import Header from '../../components/Header.astro';
const locale = 'fr'; const locale = 'fr';
--- ---
<BaseLayout locale={locale}> <BaseLayout locale={locale}>
<Header locale={locale} />
<h1>Ombrora</h1> <h1>Ombrora</h1>
</BaseLayout> </BaseLayout>
+35
View File
@@ -0,0 +1,35 @@
import { describe, it, expect, beforeAll } from 'vitest';
import { execSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
describe('Header, language switcher, theme toggle', () => {
beforeAll(() => {
execSync('npm run build', { stdio: 'inherit' });
});
it('renders the brand link on both locales', () => {
const en = readFileSync('dist/en/index.html', 'utf-8');
const fr = readFileSync('dist/fr/index.html', 'utf-8');
expect(en).toContain('class="brand" href="/en/"');
expect(fr).toContain('class="brand" href="/fr/"');
});
it('language switcher on /en/ links to /fr/ and vice versa', () => {
const en = readFileSync('dist/en/index.html', 'utf-8');
const fr = readFileSync('dist/fr/index.html', 'utf-8');
expect(en).toContain('class="language-switcher" href="/fr/"');
expect(fr).toContain('class="language-switcher" href="/en/"');
});
it('theme toggle button carries localized aria-labels', () => {
const en = readFileSync('dist/en/index.html', 'utf-8');
const fr = readFileSync('dist/fr/index.html', 'utf-8');
expect(en).toContain('aria-label="Switch to dark mode"');
expect(fr).toContain('aria-label="Passer en mode sombre"');
});
it('theme toggle click handler flips data-theme and persists to localStorage', () => {
const en = readFileSync('dist/en/index.html', 'utf-8');
expect(en).toContain("localStorage.setItem('ombrora-theme', next)");
});
});
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
fileParallelism: false,
},
});