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:
@@ -457,14 +457,16 @@ describe('BaseLayout SEO tags and theming', () => {
|
|||||||
expect(en).toContain('prefers-color-scheme: light');
|
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');
|
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**
|
- [ ] **Step 2: Run the test and confirm it fails**
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -1,12 +1,8 @@
|
|||||||
---
|
---
|
||||||
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||||
|
|
||||||
const locale = 'en';
|
const locale = 'en';
|
||||||
---
|
---
|
||||||
<html lang={locale}>
|
<BaseLayout locale={locale}>
|
||||||
<head>
|
<h1>Ombrora</h1>
|
||||||
<meta charset="utf-8" />
|
</BaseLayout>
|
||||||
<title>Ombrora</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h1>Ombrora — EN</h1>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
---
|
---
|
||||||
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||||
|
|
||||||
const locale = 'fr';
|
const locale = 'fr';
|
||||||
---
|
---
|
||||||
<html lang={locale}>
|
<BaseLayout locale={locale}>
|
||||||
<head>
|
<h1>Ombrora</h1>
|
||||||
<meta charset="utf-8" />
|
</BaseLayout>
|
||||||
<title>Ombrora</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h1>Ombrora — FR</h1>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user