当記事は前記事の続きになります。
そろそろAstroとしての開発環境の理解が進んできました。個人的にはテストが気軽に設計できるので楽ですね。それと、大規模サイトなどで静的HTML群での開発であれば環境準備諸々考えても結構良いんじゃないかな、という印象です。
それとまだ試してないですが、他のフレームワークとの依存関係も薄そう?加えてコンポーネントの設計思想が結構しっくりきます。コンポーネントごとにHTML+CSS+JSが完結するのがいいですね。
というわけで今回も色々試してみてます。
- まずは環境&プロジェクト作成
- 画像最適化 & LCP 改善(astro:assets + sharp)
- CSS/JS の標準構成
- CardGrid を実装(読みやすい 1→2→3 列)
- 404 ページ
- 外部リンクの最適化(Best Practices 100 寄せ)
- コード整形&軽Lint:Biome 導入
- テスト(Playwright)で“意図した動作をするか検証”を自動化
- 本番ビルドで計測(重要)
- 参考リンク
- まとめ
- おまけ:ソースコード一式
- src/layouts/Base.astro
- src/layouts/BaseThemed.astro(任意:テーマ差分用)
- src/pages/index.astro
- src/pages/404.astro
- src/components/CardGrid.astro
- src/components/ui/Button.astro
- src/components/ui/SafeLink.astro
- src/scripts/nav.ts
- src/scripts/secure-links.ts(任意・保険)
- src/styles/tokens.css
- src/styles/base.css
- src/styles/utilities.css
- src/lib/dom.ts
- tests(Playwright)
- astro.config.mjs(例)
- package.json(scripts だけ追記/調整)
- biome.json(最小)
- tsconfig.json(任意のパスエイリアス)
まずは環境&プロジェクト作成
PowerShell(最初にやったこと)
# 1) Node / pnpm が入っている前提
node -v
pnpm -v
# 2) Astro プロジェクト作成
pnpm create astro@latest
# 3) 開発サーバ
pnpm dev
# http://localhost:4321 を開く
画像最適化 & LCP 改善(astro:assets + sharp)
ねらい:JPEG一枚からビルド時に AVIF / WebP / JPEG を自動生成し、LCP画像をプリロードして初回描画を高速化。
PowerShell
pnpm add sharp
pnpm approve-builds
pnpm rebuild
pnpm install
src/pages/index.astro(ヒーロー画像)
---
import Base from '../layouts/Base.astro';
import { Image } from 'astro:assets';
import hero from '../assets/hero-1200x630.jpg';
import CardGrid from '../components/CardGrid.astro';
const page = {
title: 'Astro 環境セットアップ完了!',
description: 'Windows + VS Code + pnpm で Astro を動かし、Biome と Playwright まで通した実録。'
};
---
<Base title={page.title} description={page.description} preloadImage={hero} image={hero.src}>
<section class="hero">
<Image
src={hero}
alt="学習用ヒーロー画像(1200×630)"
widths={[480, 768, 1200]}
formats={['avif','webp','jpeg']}
sizes="100vw"
loading="eager"
fetchpriority="high"
/>
<h1>{page.title}</h1>
<p>{page.description}</p>
</section>
<CardGrid />
</Base>
src/layouts/Base.astro(LCPプリロード+SEOメタ)
---
import { getImage } from 'astro:assets';
import '../styles/tokens.css';
import '../styles/base.css';
import '../styles/utilities.css';
const { title='Astroデモ', description='学習メモ', image, preloadImage, preloadWidth=1200 } = Astro.props;
const site = Astro.site?.toString();
const canonical = site ? new URL(Astro.url.pathname, site).toString() : undefined;
let preloadAvif, preloadWebp;
if (preloadImage) {
const [avif, webp] = await Promise.all([
getImage({ src: preloadImage, format: 'avif', width: preloadWidth }),
getImage({ src: preloadImage, format: 'webp', width: preloadWidth }),
]);
preloadAvif = avif.src; preloadWebp = webp.src;
}
const ogImage = typeof image === 'object' ? image.src : image;
---
<!doctype html><html lang="ja"><head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title}</title>
<meta name="description" content={description} />
{canonical && <link rel="canonical" href={canonical} />}
{preloadWebp && (
<link rel="preload" as="image"
href={preloadWebp}
imagesrcset={`${preloadAvif} 1200w, ${preloadWebp} 1200w`}
imagesizes="100vw" fetchpriority="high" />
)}
<meta property="og:type" content="website" />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
{ogImage && <meta property="og:image" content={ogImage} />}
{canonical && <meta property="og:url" content={canonical} />}
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
{ogImage && <meta name="twitter:image" content={ogImage} />}
<script type="application/ld+json">
{JSON.stringify({'@context':'https://schema.org','@type':'WebSite',name:title,url:site ?? ''})}
</script>
</head>
<body>
<a href="#main" class="skip">メインコンテンツへ移動</a>
<main id="main"><slot /></main>
<!-- 共通JS(navなど最小限) -->
<script src={Astro.resolve('../scripts/nav.ts')} type="module"></script>
</body></html>
CSS/JS の標準構成
方針:
- グローバルCSSは 3枚(
tokens.css/base.css/utilities.css)+必要ならthemes/ - 見た目は各コンポーネントの
<style>に閉じる(未使用CSSを持ち込まない) - JSはサイト共通の最小のみ
scripts/に、他は局所で実装する
ディレクトリ
src/
├─ assets/ # 画像(astro:assets の最適化対象)
├─ components/
│ ├─ ui/ # 小さなUI(Button / SafeLink)
│ └─ CardGrid.astro
├─ layouts/ # Base / BaseThemed
├─ pages/ # index.astro / 404.astro
├─ scripts/ # nav.ts / secure-links.ts
└─ styles/ # tokens.css / base.css / utilities.css (+ themes/)
ダークモード(tokens.css 抜粋)
:root{
--space-1:.25rem; --space-2:.5rem; --space-3:1rem;
--radius-1:.5rem; --radius-2:.75rem; --radius-3:1rem;
--fg:oklch(20% 0 0); --bg:oklch(99% 0 0); --border:oklch(82% 0 0);
--accent:oklch(60% 0.12 250);
color-scheme: light dark;
}
@media (prefers-color-scheme: dark){
:root{ --fg:oklch(95% 0 0); --bg:oklch(16% 0 0); --border:oklch(36% 0 0); --accent:oklch(70% 0.12 250); }
}
ナビの最小JS(src/scripts/nav.ts)
document.addEventListener('DOMContentLoaded', () => {
const btn = document.querySelector<HTMLButtonElement>('[data-nav-toggle]');
const nav = document.getElementById('site-nav');
if (!btn || !nav) return;
const set = (open: boolean) => {
nav.toggleAttribute('hidden', !open);
nav.setAttribute('data-open', String(open));
btn.setAttribute('aria-expanded', String(open));
};
btn.addEventListener('click', () => set(nav.getAttribute('data-open') !== 'true'));
set(false);
});
CardGrid を実装(読みやすい 1→2→3 列)
src/components/CardGrid.astro(抜粋・CSS)
<style>
@layer components {
.cards{ container-type:inline-size }
.grid { display:grid; gap:1rem; grid-template-columns:1fr; padding:0; margin:0; list-style:none }
@container (min-width:520px){ .grid{ grid-template-columns:repeat(2,1fr) } }
@container (min-width:900px){ .grid{ grid-template-columns:repeat(3,1fr) } }
.card{ border:1px solid var(--border); border-radius:.75rem; padding:1rem; display:grid; gap:.5rem; background: color-mix(in oklch, var(--bg), black 5%) }
.card:has(.btn:focus-visible){ outline:3px solid Highlight; outline-offset:4px }
.btn { display:inline-block; border:1px solid var(--border); border-radius:.5rem; padding:.75rem 1rem; text-decoration:none }
}
</style>
404 ページ
src/pages/404.astro
---
import Base from '../layouts/Base.astro';
---
<Base title="ページが見つかりません" description="お探しのページは移動または削除されました。">
<section style="display:grid;place-content:center;gap:1rem;min-block-size:60vh;text-align:center">
<h1>404 Not Found</h1>
<p>お探しのページは見つかりませんでした。</p>
<a class="btn" href="/">トップへ戻る</a>
</section>
<style>.btn{display:inline-block;border:1px solid var(--border);border-radius:.5rem;padding:.75rem 1rem;text-decoration:none}</style>
</Base>
外部リンクの最適化(Best Practices 100 寄せ)
SafeLink コンポーネント(rel 自動補強)
src/components/ui/SafeLink.astro
---
interface Props { href: string; target?: string; rel?: string; class?: string; ariaLabel?: string; }
const { href, target, rel, class: cn, ariaLabel } = Astro.props as Props;
const parts = new Set((rel ?? '').split(/\s+/).filter(Boolean));
if (target === '_blank'){ parts.add('noopener'); parts.add('noreferrer'); }
const safeRel = Array.from(parts).join(' ') || undefined;
---
<a href={href} class={cn} target={target} rel={safeRel} aria-label={ariaLabel}>
<slot />{target === '_blank' && <span class="visually-hidden">(外部サイトへ)</span>}
</a>
念のための全体補強(任意/保険)
src/scripts/secure-links.ts
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll<HTMLAnchorElement>('a[target="_blank"]').forEach(a => {
const rel = (a.getAttribute('rel') ?? '').split(/\s+/).filter(Boolean);
if (!rel.includes('noopener')) rel.push('noopener');
if (!rel.includes('noreferrer')) rel.push('noreferrer');
a.setAttribute('rel', rel.join(' '));
});
});
読み込みは
Base.astroの</body>直前に<script src={Astro.resolve('../scripts/secure-links.ts')} type="module"></script>
コード整形&軽Lint:Biome 導入
PowerShell
pnpm add -D @biomejs/biome
# repo に biome.json を置いたら
pnpm run lint # = biome check --write .
package.json(抜粋)
{ "scripts": { "lint": "biome check --write .", "test:e2e": "playwright test" } }
テスト(Playwright)で“意図した動作をするか検証”を自動化
PowerShell
# devサーバでOK(本番相当で見たいときは preview のURLへ)
pnpm dev
pnpm exec playwright test
追加したテスト(要点)
smoke.spec.ts… トップが開けるconsole.spec.ts… Console error なしhero.spec.ts… ヒーロー画像(fetchpriority=”high”)が表示externallinks.spec.ts…target=_blankにrel=noopenernav.spec.ts… メニュー開閉- (任意)
a11y.spec.ts/theme.spec.ts… mainの存在やトークン適用の簡易確認
例:tests/externallinks.spec.ts
import { test, expect } from '@playwright/test';
test('外部リンクに rel=noopener が付与されている', async ({ page }) => {
await page.goto('http://localhost:4321/');
const blanks = page.locator('a[target="_blank"]');
const count = await blanks.count();
for (let i = 0; i < count; i++) {
const rel = await blanks.nth(i).getAttribute('rel');
expect(rel || '').toMatch(/noopener/);
}
});
本番ビルドで計測(重要)
pnpm build
pnpm preview # http://localhost:4321 を Lighthouse で計測
- LCPのプリロード警告が消える
- 次世代画像(AVIF/WebP)で配信
- Best Practices は
SafeLink(+必要ならsecure-links.ts)で安定
参考リンク
- Astro 公式ドキュメント:https://docs.astro.build/
- 画像最適化(astro:assets):https://docs.astro.build/en/guides/images/
- Playwright:https://playwright.dev/
- Biome:https://biomejs.dev/
まとめ
- 静的サイトでも“体感が速い”は作れる:ローカル画像の最適化+LCPプリロードが効く
- 構成はシンプルに:グローバルCSSは3枚/見た目はコンポーネント内に閉じる/JSは最小限
- 仕上げの安全網:リンク最適化・404・E2Eテストで“壊れにくい”基盤に
広告
おまけ:ソースコード一式
src/layouts/Base.astro
---
import { getImage } from 'astro:assets';
import '../styles/tokens.css';
import '../styles/base.css';
import '../styles/utilities.css';
const {
title = 'Astroデモサイト',
description = 'Astro + pnpm + Biome + Playwright の学習メモ',
image = undefined, // 文字列URL or assets の .src
preloadImage = undefined, // LCPプリロードしたい assets 画像
preloadWidth = 1200
} = Astro.props;
const site = Astro.site?.toString();
const canonical = site ? new URL(Astro.url.pathname, site).toString() : undefined;
let preloadAvif: string | undefined;
let preloadWebp: string | undefined;
if (preloadImage) {
const [avif, webp] = await Promise.all([
getImage({ src: preloadImage, format: 'avif', width: preloadWidth }),
getImage({ src: preloadImage, format: 'webp', width: preloadWidth }),
]);
preloadAvif = avif?.src;
preloadWebp = webp?.src;
}
const ogImage =
typeof image === 'object' && (image as any)?.src ? (image as any).src :
typeof image === 'string' ? image : undefined;
---
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title}</title>
<meta name="description" content={description} />
{canonical && <link rel="canonical" href={canonical} />}
{preloadWebp && (
<link
rel="preload" as="image" href={preloadWebp}
imagesrcset={`${preloadAvif ? `${preloadAvif} 1200w, ` : ''}${preloadWebp} 1200w`}
imagesizes="100vw" fetchpriority="high"
/>
)}
<!-- Open Graph / Twitter -->
<meta property="og:type" content="website" />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
{ogImage && <meta property="og:image" content={ogImage} />}
{canonical && <meta property="og:url" content={canonical} />}
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
{ogImage && <meta name="twitter:image" content={ogImage} />}
<!-- 構造化データ(WebSite) -->
<script type="application/ld+json">
{JSON.stringify({
'@context': 'https://schema.org',
'@type': 'WebSite',
name: title,
url: site ?? ''
})}
</script>
</head>
<body>
<a href="#main" class="skip">メインコンテンツへ移動</a>
<main id="main">
<slot />
</main>
<!-- 共通JS(必要最小限) -->
<script src={Astro.resolve('../scripts/nav.ts')} type="module"></script>
<!-- 任意:外部リンクrelの保険 -->
<!-- <script src={Astro.resolve('../scripts/secure-links.ts')} type="module"></script> -->
</body>
</html>
<style>
.skip { position: absolute; left: -999px; top: 0; }
.skip:focus {
left: 1rem; top: 1rem; z-index: 1000;
background: Canvas; color: CanvasText;
padding: .5rem .75rem; border-radius: .5rem;
outline: 2px solid Highlight;
}
</style>
src/layouts/BaseThemed.astro(任意:テーマ差分用)
---
import Base from './Base.astro';
const { title, description, ...rest } = Astro.props;
---
<Base {title} {description} {...rest}>
<section class="themed">
<slot />
</section>
<style>
.themed { background: var(--bg); color: var(--fg); }
</style>
</Base>
src/pages/index.astro
---
import Base from '../layouts/Base.astro';
import { Image } from 'astro:assets';
import hero from '../assets/hero-1200x630.jpg';
import CardGrid from '../components/CardGrid.astro';
const page = {
title: 'Astro 環境セットアップ完了!',
description: 'Windows + VS Code + pnpm で Astro を動かし、Biome と Playwright まで通した実録。'
};
---
<Base
title={page.title}
description={page.description}
preloadImage={hero}
image={hero.src}
>
<div class="container">
<section class="hero section">
<Image
src={hero}
alt="学習用ヒーロー画像(1200×630)"
widths={[480, 768, 1200]}
formats={['avif','webp','jpeg']}
sizes="100vw"
loading="eager"
fetchpriority="high"
/>
<h1>{page.title}</h1>
<p>{page.description}</p>
</section>
<section class="section">
<CardGrid />
</section>
</div>
</Base>
<style>
.hero { display: grid; gap: 1rem; align-content: start; }
.hero :where(picture, img){ width:100%; height:auto; border-radius: .75rem; }
</style>
src/pages/404.astro
---
import Base from '../layouts/Base.astro';
---
<Base title="ページが見つかりません" description="お探しのページは移動または削除されました。">
<section style="display:grid;place-content:center;gap:1rem;min-block-size:60vh;text-align:center">
<h1>404 Not Found</h1>
<p>お探しのページは見つかりませんでした。</p>
<a class="btn" href="/">トップへ戻る</a>
</section>
<style>
.btn{display:inline-block;border:1px solid var(--border);border-radius:.5rem;padding:.75rem 1rem;text-decoration:none}
</style>
</Base>
src/components/CardGrid.astro
---
const items = Array.from({ length: 8 }, (_, i) => ({
title: `Card ${i + 1}`,
body: 'デモ用テキスト。キーボード操作やフォーカス表示を確認できます。'
}));
---
<section class="cards" aria-labelledby="cards-heading">
<h2 id="cards-heading" class="visually-hidden">カード一覧</h2>
<ul role="list" class="grid">
{items.map((it) => (
<li class="card">
<h3>{it.title}</h3>
<p>{it.body}</p>
<a class="btn" href="#" aria-label={`${it.title} の詳細へ`}>
{it.title} の詳細を見る
</a>
</li>
))}
</ul>
</section>
<style>
@layer components {
.visually-hidden{position:absolute!important;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
.cards{ container-type:inline-size }
.grid { display:grid; gap:1rem; grid-template-columns:1fr; padding:0; margin:0; list-style:none }
@container (min-width:520px){ .grid{ grid-template-columns:repeat(2,1fr) } }
@container (min-width:900px){ .grid{ grid-template-columns:repeat(3,1fr) } }
.card{ border:1px solid var(--border); border-radius:.75rem; padding:1rem; display:grid; gap:.5rem;
background: color-mix(in oklch, var(--bg), black 5%) }
.card:has(.btn:focus-visible){ outline:3px solid Highlight; outline-offset:4px }
.btn { display:inline-block; border:1px solid var(--border); border-radius:.5rem; padding:.75rem 1rem; text-decoration:none }
}
</style>
src/components/ui/Button.astro
---
const { kind = "primary" } = Astro.props as { kind?: "primary" | "ghost" };
---
<button class={`btn ${kind}`}><slot /></button>
<style>
@layer components {
.btn { border:1px solid var(--border); border-radius:.5rem; padding:.5rem .75rem; }
.btn.primary{ background: color-mix(in oklch, var(--accent), white 85%); }
.btn.ghost{ background: transparent; }
.btn:focus-visible{ outline:2px solid Highlight; outline-offset:2px; }
}
</style>
src/components/ui/SafeLink.astro
---
interface Props { href: string; target?: string; rel?: string; class?: string; ariaLabel?: string; }
const { href, target, rel, class: cn, ariaLabel } = Astro.props as Props;
const parts = new Set((rel ?? '').split(/\s+/).filter(Boolean));
if (target === '_blank'){ parts.add('noopener'); parts.add('noreferrer'); }
const safeRel = Array.from(parts).join(' ') || undefined;
---
<a href={href} class={cn} target={target} rel={safeRel} aria-label={ariaLabel}>
<slot />
{target === '_blank' && <span class="visually-hidden">(外部サイトへ)</span>}
</a>
(src/components/Welcome.astro は任意のため省略)
src/scripts/nav.ts
document.addEventListener('DOMContentLoaded', () => {
const btn = document.querySelector<HTMLButtonElement>('[data-nav-toggle]');
const nav = document.getElementById('site-nav');
if (!btn || !nav) return;
const set = (open: boolean) => {
nav.toggleAttribute('hidden', !open);
nav.setAttribute('data-open', String(open));
btn.setAttribute('aria-expanded', String(open));
};
btn.addEventListener('click', () => {
const open = nav.getAttribute('data-open') === 'true';
set(!open);
});
set(false);
});
src/scripts/secure-links.ts(任意・保険)
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll<HTMLAnchorElement>('a[target="_blank"]').forEach(a => {
const rel = (a.getAttribute('rel') ?? '').split(/\s+/).filter(Boolean);
if (!rel.includes('noopener')) rel.push('noopener');
if (!rel.includes('noreferrer')) rel.push('noreferrer');
a.setAttribute('rel', rel.join(' '));
});
});
src/styles/tokens.css
:root{
--space-1:.25rem; --space-2:.5rem; --space-3:1rem; --space-4:1.5rem; --space-5:2rem;
--radius-1:.5rem; --radius-2:.75rem; --radius-3:1rem;
--fg:oklch(20% 0 0); --bg:oklch(99% 0 0); --border:oklch(82% 0 0); --accent:oklch(60% .12 250);
--container-max: 72rem; /* ≒1152px */
--container-pad: 1rem; /* 16px */
color-scheme: light dark;
}
@media (prefers-color-scheme: dark){
:root{ --fg:oklch(95% 0 0); --bg:oklch(16% 0 0); --border:oklch(36% 0 0); --accent:oklch(70% .12 250); }
}
@media (min-width: 1200px){
:root{ --container-max: 80rem; }
}
src/styles/base.css
@layer reset, base, components, utilities;
/* reset */
@layer reset {
*,*::before,*::after{ box-sizing:border-box }
html:focus-within{ scroll-behavior:smooth }
body{ margin:0; font-family:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
background:var(--bg); color:var(--fg) }
img,svg,video,canvas{ max-width:100%; display:block }
a{ color:inherit }
}
/* base */
@layer base {
:where(h1,h2,h3){ line-height:1.2; margin: var(--space-4) 0 var(--space-2) }
:where(p,ul,ol){ margin: 0 0 var(--space-3) }
:where(button,[role="button"],.btn){ cursor:pointer }
:where(h1,h2){ text-wrap: balance; } /* 自然な改行 */
/* ヒーロー画像の角丸(任意) */
:where(.hero) :where(picture, img){ border-radius: var(--radius-3); }
}
src/styles/utilities.css
@layer utilities{
.visually-hidden{ position:absolute!important; width:1px; height:1px; padding:0; margin:-1px;
overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0 }
.skip{ position:absolute; left:-999px; top:0 }
.skip:focus{ left:1rem; top:1rem; z-index:1000; background:Canvas; color:CanvasText;
padding:.5rem .75rem; border-radius:.5rem; outline:2px solid Highlight }
/* コンテンツの“器”とセクションのリズム */
.container{ inline-size:min(var(--container-max), 100% - 2*var(--container-pad)); margin-inline:auto; }
.section{ margin-block: clamp(1.25rem, 3vw, 2rem); }
/* リンクの視認性(任意) */
a{ text-decoration-thickness:.08em; text-underline-offset:.2em; }
a:hover{ text-decoration-thickness:.12em; }
}
src/lib/dom.ts
export const qs = <T extends Element>(sel: string, root: ParentNode = document) =>
root.querySelector<T>(sel);
tests(Playwright)
開発中なら
http://localhost:4321/、本番相当ならpnpm previewのURLに向けてもOK。
tests/smoke.spec.ts
import { test, expect } from '@playwright/test';
test('トップページが開ける', async ({ page }) => {
const res = await page.goto('http://localhost:4321/');
expect(res?.ok()).toBeTruthy();
await expect(page.locator('main')).toBeVisible();
});
tests/console.spec.ts
import { test, expect } from '@playwright/test';
test('コンソールに error が出ていない', async ({ page }) => {
const errors: string[] = [];
page.on('console', (msg) => msg.type() === 'error' && errors.push(msg.text()));
await page.goto('http://localhost:4321/');
expect(errors).toEqual([]);
});
tests/hero.spec.ts
import { test, expect } from '@playwright/test';
test('ヒーロー画像(LCP候補)が表示されている', async ({ page }) => {
await page.goto('http://localhost:4321/');
await expect(page.locator('img[fetchpriority="high"]')).toBeVisible();
});
tests/externallinks.spec.ts
import { test, expect } from '@playwright/test';
test('外部リンクに rel=noopener が付与されている', async ({ page }) => {
await page.goto('http://localhost:4321/');
const blanks = page.locator('a[target="_blank"]');
const count = await blanks.count();
for (let i = 0; i < count; i++) {
const rel = await blanks.nth(i).getAttribute('rel');
expect(rel || '').toMatch(/noopener/);
}
});
tests/nav.spec.ts
import { test, expect } from '@playwright/test';
test('メニューが開閉できる', async ({ page }) => {
await page.goto('http://localhost:4321/');
const btn = page.getByRole('button', { name: /メニュー/ });
const nav = page.locator('#site-nav');
await expect(nav).toBeHidden();
await btn.click();
await expect(nav).toBeVisible();
await btn.click();
await expect(nav).toBeHidden();
});
tests/a11y.spec.ts(簡易)
import { test, expect } from '@playwright/test';
test('main と h1 が存在する', async ({ page }) => {
await page.goto('http://localhost:4321/');
await expect(page.locator('main')).toBeVisible();
await expect(page.locator('h1')).toHaveCount(1);
});
tests/theme.spec.ts(ダークモードの簡易確認)
import { test, expect } from '@playwright/test';
test('prefers-color-scheme: dark で背景色が変わる(目視の代替)', async ({ page }) => {
await page.emulateMedia({ colorScheme: 'light' });
await page.goto('http://localhost:4321/');
const lightBg = await page.evaluate(() => getComputedStyle(document.body).backgroundColor);
await page.emulateMedia({ colorScheme: 'dark' });
await page.reload();
const darkBg = await page.evaluate(() => getComputedStyle(document.body).backgroundColor);
expect(lightBg).not.toBe(darkBg);
});
astro.config.mjs(例)
import { defineConfig } from 'astro/config';
// import sitemap from '@astrojs/sitemap'; // 使うなら有効化
export default defineConfig({
// site: 'https://example.com',
// integrations: [sitemap()],
});
package.json(scripts だけ追記/調整)
{
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro",
"lint": "biome check --write .",
"test:e2e": "playwright test"
}
}
biome.json(最小)
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"formatter": { "enabled": true, "indentWidth": 2, "lineWidth": 100 },
"linter": {
"enabled": true,
"rules": {
"style": { "useConsistentSpacing": "warn" },
"correctness": { "noUnusedVariables": "warn" }
}
},
"organizeImports": { "enabled": true }
}
tsconfig.json(任意のパスエイリアス)
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@styles/*": ["src/styles/*"],
"@components/*": ["src/components/*"],
"@lib/*": ["src/lib/*"],
"@scripts/*": ["src/scripts/*"]
}
}
}
広告
自宅で現役エンジニアから学べる TechAcademy
1人ではプログラミング学習が続かない方のための、パーソナルメンターがつく「オンラインブートキャンプ」です。
オンラインブートキャンプの特徴は下記の3つがあります。
1.スクールに通わなくても、自宅などでオンライン学習できる
2.わからないことはいつでもチャットでメンターに質問できる
3.パーソナルメンターがついてオリジナルサービスやオリジナルアプリの公開までサポート
自分のオリジナルサービスやアプリを開発しながらプログラミングを実践的に学んでいただき、
わからないことはいつでも現役エンジニアのメンターに相談することができます。
詳細はこちらをご覧ください。
https://px.a8.net/svt/ejp?a8mat=45BXMR+GHL6WI+3GWO+5YZ77







