当記事は前記事の続きになります。
当記事は前回作成した環境をベースにLCP改善につながるような最適化だったり、メタ情報の更新や構造化等の対応などがどこまでできるのか実験的に対応しています。
今日のゴール
- ヒーローイメージを astro:assets で最適化(AVIF/WebP/JPEG自動生成)+プリロードで LCP 改善
- レイアウトに SEOメタ+構造化データ+canonical を集約
- CardGrid(カードUI)を実装:コンテナクエリ +
:has()+ A11y(フォーカス・44pxタップ) - Lighthouse 計測(本番ビルド)でほぼ100取得
変更の全体像
- 画像を
src/assets/に移動 →<Image />(astro:assets)で最適化出力 - 画像最適化に必要な Sharp を導入
Base.astroに プリロードとSEOメタを実装- CardGrid を新規作成 → トップページに読み込み
- 本番ビルド → Lighthouse 計測
PowerShell:実行コマンドまとめ(コピペ可)
powershell:
# 0) 画像の置き場所
mkdir src\assets -Force
# 1) 依存導入(astro:assets が使う sharp)
pnpm add sharp
pnpm approve-builds
pnpm rebuild
pnpm install
# 2) 本番ビルド→プレビュー(計測は必ずこちらで)
pnpm build
pnpm preview # http://localhost:4321
変更ファイル①:src/layouts/Base.astro(完成形)
LCPプリロード+SEOメタを集約。
preloadImageにsrc/assets/...を渡すと AVIF/WebP を生成して<link rel="preload">を出します。
Astro:src/layouts/Base.astro
---
import { getImage } from 'astro:assets';
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, 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.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"
/>
)}
<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>
</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/components/CardGrid.astro(新規・完成形)
コンテナクエリで列数が“親の幅”に合わせて自律変化。
:has()で子のフォーカスに合わせてカードを強調。
リンク文言はカード名を含める+aria-labelで SEO / A11y を両立。
---
const items = Array.from({ length: 8 }, (_, i) => ({
title: `Card ${i + 1}`,
body: 'キーボード操作やフォーカス表示を確認できます。'
}));
---
<section class="cards" aria-labelledby="cards-heading">
<h2 id="cards-heading" class="sr-only">カード一覧</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>
/* 視覚非表示(スクリーンリーダー用) */
.sr-only {
position: absolute; 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(4, 1fr); } }
.card {
border: 1px solid oklch(80% 0 0 / .6);
border-radius: .75rem;
padding: 1rem;
display: grid;
gap: .5rem;
background: white;
}
/* 子の .btn がフォーカス可視なら親カードを強調 */
.card:has(.btn:focus-visible) {
outline: 3px solid Highlight;
outline-offset: 4px;
}
/* タップターゲットを 44px 目安に確保(WCAG 2.2) */
.btn {
display: inline-block;
border: 1px solid oklch(40% 0 0 / .3);
border-radius: .5rem;
padding: .75rem 1rem;
text-decoration: none;
}
.btn:focus-visible { outline: 2px solid Highlight; outline-offset: 2px; }
</style>
変更ファイル③:src/pages/index.astro(完成形)
ヒーローは
<Image />、下段に CardGrid を表示。
---
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>
<style>
.hero {
display: grid;
gap: 1rem;
align-content: start;
}
.hero :where(picture, img) {
width: 100%;
height: auto;
border-radius: .75rem;
}
</style>
計測結果(本番ビルドで)
pnpm build
pnpm preview # http://localhost:4321 を Lighthouse で計測
- Performance 100 / Accessibility 100 / SEO 100
- Best Practices は外部リンクの
rel="noopener noreferrer"などでさらに上げられます
仕上げ(任意)
@astrojs/sitemapで sitemap 生成+public/robots.txt- デプロイ先のセキュリティヘッダー(CSP / Referrer-Policy など)
付録:今日入れた E2E テスト(Playwright)
本番ビルドでの計測に加え、基本が壊れていないことを自動で確認するために Playwright の E2E テストを追加しました。
開発中は pnpm dev を起動したまま、別ターミナルでテストを実行します。
powershell実行コマンド
# ターミナルA
pnpm dev
# ターミナルB(テスト実行)
pnpm run test:e2e
# もしくは
pnpm exec playwright test
1) スモークテスト:トップページが開ける
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(); // 200系で応答
await expect(page.locator('main')).toBeVisible(); // mainが見える
});
2) コンソールエラーが出ていない(Best Practices対策)
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([]); // 1つも出ていないこと
});
3) LCP対策のヒーロー画像がある(プリロード対象の存在確認)
tests/hero.spec.ts
import { test, expect } from '@playwright/test';
test('ヒーロー画像(LCP候補)が表示されている', async ({ page }) => {
await page.goto('http://localhost:4321/');
// fetchpriority="high" を付与したヒーロー画像の存在を確認
const hero = page.locator('img[fetchpriority="high"]');
await expect(hero).toBeVisible();
});
補足
- 本番相当で動かす場合は
pnpm build && pnpm previewのURLに向けてもOKです。- テストは回帰防止にも有効(例:外部リンクの付け替えでConsoleエラーが出ないか等)。CIに組み込むと安心度が上がります。







