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:
@@ -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`
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
fileParallelism: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user