feat: add BaseLayout with SEO head tags and theme init script

Canonical/hreflang/OG tags derived from the locale dictionary, plus a
pre-paint script applying data-theme from localStorage or system
preference.
This commit is contained in:
2026-08-02 15:54:42 +02:00
parent 2606508713
commit 1d65bb7aa8
6 changed files with 238 additions and 21 deletions
@@ -457,14 +457,16 @@ describe('BaseLayout SEO tags and theming', () => {
expect(en).toContain('prefers-color-scheme: light');
});
it('links the global stylesheet', () => {
it('applies the global stylesheet (inlined or linked)', () => {
const en = readFileSync('dist/en/index.html', 'utf-8');
expect(en).toMatch(/<link rel="stylesheet"[^>]*href="[^"]+\.css"/);
const hasInlineStyle = /<style>[\s\S]*--accent[\s\S]*<\/style>/.test(en);
const hasStylesheetLink = /<link rel="stylesheet"[^>]*href="[^"]+\.css"/.test(en);
expect(hasInlineStyle || hasStylesheetLink).toBe(true);
});
});
```
Note: from this task on, tests call `npm run build` themselves via `beforeAll`, so `npm run test` alone is sufficient going forward.
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.
- [ ] **Step 2: Run the test and confirm it fails**
+46
View File
@@ -0,0 +1,46 @@
---
import '../styles/global.css';
import { getDictionary } from '../i18n';
import { otherLocale, swapLocalePath } from '../i18n/locale';
import type { Locale } from '../i18n/locale';
interface Props {
locale: Locale;
}
const { locale } = Astro.props;
const t = getDictionary(locale);
const target = otherLocale(locale);
const alternatePath = swapLocalePath(Astro.url.pathname, locale, target);
const canonicalURL = new URL(Astro.url.pathname, Astro.site);
const alternateURL = new URL(alternatePath, Astro.site);
const enURL = locale === 'en' ? canonicalURL : alternateURL;
const frURL = locale === 'fr' ? canonicalURL : alternateURL;
---
<!doctype html>
<html lang={locale} data-theme="dark">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{t.meta.title}</title>
<meta name="description" content={t.meta.description} />
<link rel="canonical" href={canonicalURL} />
<link rel="alternate" hreflang="en" href={enURL} />
<link rel="alternate" hreflang="fr" href={frURL} />
<link rel="alternate" hreflang="x-default" href={enURL} />
<meta property="og:title" content={t.meta.title} />
<meta property="og:description" content={t.meta.description} />
<meta property="og:type" content="website" />
<meta property="og:url" content={canonicalURL} />
<script is:inline>
(function () {
var stored = localStorage.getItem('ombrora-theme');
var theme = stored || (window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark');
document.documentElement.setAttribute('data-theme', theme);
})();
</script>
</head>
<body>
<slot />
</body>
</html>
+5 -9
View File
@@ -1,12 +1,8 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
const locale = 'en';
---
<html lang={locale}>
<head>
<meta charset="utf-8" />
<title>Ombrora</title>
</head>
<body>
<h1>Ombrora — EN</h1>
</body>
</html>
<BaseLayout locale={locale}>
<h1>Ombrora</h1>
</BaseLayout>
+5 -9
View File
@@ -1,12 +1,8 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
const locale = 'fr';
---
<html lang={locale}>
<head>
<meta charset="utf-8" />
<title>Ombrora</title>
</head>
<body>
<h1>Ombrora — FR</h1>
</body>
</html>
<BaseLayout locale={locale}>
<h1>Ombrora</h1>
</BaseLayout>
+137
View File
@@ -0,0 +1,137 @@
:root {
--bg: #ffffff;
--fg: #14171a;
--muted: #5b6470;
--accent: #6d5bff;
--card-bg: #f4f5f7;
--border: #e1e4e8;
}
:root[data-theme='dark'] {
--bg: #0d0f12;
--fg: #e6e8eb;
--muted: #9aa3ad;
--accent: #8f7cff;
--card-bg: #16191d;
--border: #262b31;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme='light']) {
--bg: #0d0f12;
--fg: #e6e8eb;
--muted: #9aa3ad;
--accent: #8f7cff;
--card-bg: #16191d;
--border: #262b31;
}
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--bg);
color: var(--fg);
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
line-height: 1.5;
}
.site-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border);
}
.header-actions {
display: flex;
gap: 0.75rem;
align-items: center;
}
.brand {
font-weight: 700;
color: var(--fg);
text-decoration: none;
}
.language-switcher {
color: var(--muted);
text-decoration: none;
font-size: 0.9rem;
}
.theme-toggle {
background: transparent;
border: 1px solid var(--border);
border-radius: 999px;
padding: 0.25rem 0.6rem;
cursor: pointer;
color: var(--fg);
}
main {
max-width: 960px;
margin: 0 auto;
padding: 2rem 1.5rem 4rem;
}
.hero {
padding: 3rem 0;
}
.hero h1 {
font-size: clamp(2rem, 4vw, 2.75rem);
margin: 0 0 0.75rem;
}
.hero p {
color: var(--muted);
max-width: 40ch;
}
.services-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 1.25rem;
}
.service-card {
display: block;
padding: 1.25rem;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--card-bg);
text-decoration: none;
color: var(--fg);
}
.service-card__tag {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.75rem;
color: var(--accent);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.service-card__name {
margin: 0.4rem 0 0.35rem;
}
.service-card__description {
color: var(--muted);
margin: 0;
font-size: 0.95rem;
}
.site-footer {
padding: 1.5rem;
text-align: center;
color: var(--muted);
font-size: 0.85rem;
border-top: 1px solid var(--border);
}
+40
View File
@@ -0,0 +1,40 @@
import { describe, it, expect, beforeAll } from 'vitest';
import { execSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
describe('BaseLayout SEO tags and theming', () => {
beforeAll(() => {
execSync('npm run build', { stdio: 'inherit' });
});
it('sets canonical and hreflang alternates pointing at each other', () => {
const en = readFileSync('dist/en/index.html', 'utf-8');
const fr = readFileSync('dist/fr/index.html', 'utf-8');
expect(en).toContain('rel="canonical" href="https://www.ombrora.com/en/"');
expect(en).toContain('hreflang="fr" href="https://www.ombrora.com/fr/"');
expect(fr).toContain('rel="canonical" href="https://www.ombrora.com/fr/"');
expect(fr).toContain('hreflang="en" href="https://www.ombrora.com/en/"');
});
it('uses the locale-specific meta title and description', () => {
const en = readFileSync('dist/en/index.html', 'utf-8');
const fr = readFileSync('dist/fr/index.html', 'utf-8');
expect(en).toContain('<title>Ombrora — Security tracking and file tools</title>');
expect(fr).toContain('<title>Ombrora — Veille sécurité et outils de fichiers</title>');
});
it('includes a pre-paint theme script keyed on ombrora-theme', () => {
const en = readFileSync('dist/en/index.html', 'utf-8');
expect(en).toContain("localStorage.getItem('ombrora-theme')");
expect(en).toContain('prefers-color-scheme: light');
});
it('applies the global stylesheet (inlined or linked)', () => {
const en = readFileSync('dist/en/index.html', 'utf-8');
const hasInlineStyle = /<style>[\s\S]*--accent[\s\S]*<\/style>/.test(en);
const hasStylesheetLink = /<link rel="stylesheet"[^>]*href="[^"]+\.css"/.test(en);
expect(hasInlineStyle || hasStylesheetLink).toBe(true);
});
});