【学習中】Astroで“速い&整った”サイトを作る(ダークモード/画像最適化/Biome/404/リンク最適化/E2Eテスト)

コーディング
この記事は約43分で読めます。
本ページはプロモーション(広告)が含まれています

当記事は前記事の続きになります。

そろそろAstroとしての開発環境の理解が進んできました。個人的にはテストが気軽に設計できるので楽ですね。それと、大規模サイトなどで静的HTML群での開発であれば環境準備諸々考えても結構良いんじゃないかな、という印象です。

それとまだ試してないですが、他のフレームワークとの依存関係も薄そう?加えてコンポーネントの設計思想が結構しっくりきます。コンポーネントごとにHTML+CSS+JSが完結するのがいいですね。

というわけで今回も色々試してみてます。

まずは環境&プロジェクト作成

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.tstarget=_blankrel=noopener
  • nav.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)で安定

参考リンク


まとめ

  • 静的サイトでも“体感が速い”は作れる:ローカル画像の最適化+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

タイトルとURLをコピーしました