lism.blog
検索
MENU

blog-astro-techlog の構成

Lism CSS リポジトリの templates/blog/astro/techlog/ には、Lism CSS と @lism-css/ui を使った技術ブログ向けの Astro テンプレートが入っている。コードハイライトや記事内 TOC、カテゴリ/タグ、年月アーカイブ、Pagefind による全文検索など、技術記事を書くうえで欲しい機能をまとめて揃えてある。この記事ではそのディレクトリ構成と動作仕様を整理する。

依存関係

package.json の依存関係は次の通り。Astro と Lism CSS / @lism-css/ui に加え、MDX・::: 記法・コードハイライト・全文検索のためのパッケージを入れている。

{
"dependencies": {
"@astrojs/mdx": "^5.0.3",
"@lism-css/ui": "workspace:*",
"@pagefind/default-ui": "^1.4.0",
"astro": "^6.1.9",
"astro-expressive-code": "^0.41.7",
"lism-css": "workspace:*",
"pagefind": "^1.4.0",
"remark-directive": "^4.0.0",
"unist-util-visit": "^5.1.0"
}
}

astro.config.mjs では astro-expressive-code@astrojs/mdx を有効化し、markdown.remarkPluginsremark-directive と自前の変換プラグイン(src/lib/remark-directive.mjs)、URL段落をリンクカードに展開する src/lib/remark-link-card.mjs[[slug]]記法を内部リンクに展開する src/lib/remark-wiki-link.mjs を登録している。@/srcにエイリアスしている。

expressiveCodemdx より前に並べる必要がある点に注意。

integrations: [
expressiveCode({
themes: 'github-dark',
defaultProps: { wrap: true },
styleOverrides: {
codeFontFamily: 'var(--ff--mono)',
borderRadius: 'var(--bdrs--10)',
frames: {
frameBoxShadowCssValue: 'none',
},
},
}),
mdx(),
],

ディレクトリ構成

src/
├── components/ # Astro コンポーネント
├── config/ # サイト設定・カテゴリ・ナビ
├── content.config.ts
├── layouts/ # ページレイアウト
├── lib/ # 純粋ロジック(TOC生成・年月アーカイブ集計 など)
├── pages/ # ルーティング
├── posts/ # 記事 MDX(カテゴリごとにディレクトリ)
│ ├── tech/
│ └── column/
└── styles/
└── global.css

Content Collections

src/content.config.ts で記事用コレクションを定義している。

import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';
const posts = defineCollection({
loader: glob({ base: './src/posts', pattern: '**/*.mdx' }),
schema: z.object({
title: z.string(),
excerpt: z.string(),
date: z.string(),
tags: z.array(z.string()).default([]),
}),
});
export const collections = { posts };

pattern: '**/*.mdx' のため、src/posts/tech/foo.mdx のようにディレクトリ階層を切れる。記事 ID は tech/foo のような形になり、先頭ディレクトリをカテゴリ、残りを記事 slug として扱う。

/about//privacy/ のような単発の固定ページは Content Collections には載せず、src/pages/about.astro のように .astro ファイルとして直接配置しているので、レイアウトやコンポーネントを自由に組める。

MDX と Callout / Alert

このテンプレートは記事を .mdx 前提で書く構成にしている。本文中で Astro / React コンポーネントをそのまま使えるほか、::: 記法で @lism-css/uiCallout / Alert を呼び出せるよう、src/lib/remark-directive.mjs に変換プラグインを置いている。

ラベル付きの :::type[タイトル]<Callout type="..." title="..."> に、ラベルを省略した :::type<Alert type="..."> に変換される。

:::point[ラベル付きの呼び出し]
ラベルがあるので Callout に変換されます。
:::
:::warning
ラベルを省略するとタイトル領域のない Alert になります。
:::

対応する type は alert / point / warning / check / help

Callout / Alert / LinkCard / WikiLink コンポーネントは posts/[...slug].astro<Content components={{ Callout, Alert, LinkCard, WikiLink }} /> としてグローバルに供給しているので、記事ファイル側で毎回 import する必要はない。

なお remark-directive 自体は .md でも動作するが、このテンプレートのプラグインは ::: を JSX ノードに変換するため、JSX を解釈できない .md のレンダーパイプラインでは機能しない。content.config.tspattern**/*.mdx に絞ってあり、.md を置いても収集対象から外れる。

コードハイライト

コードハイライトは astro-expressive-code に任せている。Shiki ベースで動き、Astro のビルド時にコードブロックを静的にハイライト済みの HTML に変換する。クライアントランタイムは不要。

テーマは github-dark を単一指定しており、ライト/ダーク両モードで同じ配色(GitHub Dark)を表示する。ライト/ダーク両用テーマを切り替えたい場合は themes: ['github-dark', 'github-light'] のように配列で渡し、themeCssSelector: (theme) => `[data-theme='${theme.type}']` を追加することでサイトのテーマトグルと連動させられる。

```ts title="hello.ts"
export function hello(name: string) {
return `Hello, ${name}!`;
}
```

title="..." でファイル名タブ、行番号や差分強調などの追加記法も Expressive Code の標準機能としてそのまま使える。フォントは --ff--mono、角丸は --bdrs--xs を参照させて Lism CSS のトークンに合わせている。

リンクカード / WikiLink(自動展開)

URL文字列だけの段落は、ビルド時に自動で <LinkCard type="external"> に展開される。対応するremarkプラグインは src/lib/remark-link-card.mjs

<!-- 外部 URL だけの段落 <LinkCard type="external">(ビルド時に OGP を fetch) -->
https://lism-css.com/

段落単独の[[slug]]<LinkCard type="internal">に、文中の[[slug]]/[[slug|表示テキスト]]<WikiLink>に展開される。対応するremarkプラグインは src/lib/remark-wiki-link.mjsslugsrc/posts/{category}/配下のファイル名(拡張子なし)を指定する。

<!-- [[slug]]だけの段落 <LinkCard type="internal">(Content Collectionsから記事情報を引く) -->
[[lism-css-intro]]
<!-- 文中の [[slug]] 記事タイトルを表示する <a> リンク -->
詳しくは [[lism-css-intro]] を参照。
<!-- [[slug|表示テキスト]] エイリアス付きリンク -->
詳しくは [[lism-css-intro|前回の記事]] を参照。

外部リンクのOGPは.cache/ogp/にMD5ハッシュキー付きJSONでキャッシュされる(TTL 7日)。fetchに失敗したり、メタデータを上書きしたいときはMDX上で<LinkCard type="external" href="..." title="..." description="..." />を直書きすればよい。

カテゴリ設計(ディレクトリ=カテゴリ)

カテゴリはフロントマターには書かず、src/posts/{category}/ の置き場所で決まる。src/config/categories.ts にカテゴリ定義(データ)を、src/lib/posts.ts に post.id と URL を扱うユーティリティ(isCategoryKey / parsePostId / getPostHref / getCategoryHref / getTagHref)を置いている。

src/config/categories.ts
export type CategoryKey = 'tech' | 'column';
export const CATEGORIES: Record<CategoryKey, Category> = {
tech: { key: 'tech', label: 'TECH', description: '技術記事と開発メモ' },
column: { key: 'column', label: 'COLUMN', description: '技術以外のコラムと雑記' },
};
src/lib/posts.ts
export function parsePostId(id: string): { category: CategoryKey; slug: string } {
const [category, ...rest] = id.split('/');
if (!isCategoryKey(category)) throw new Error(`Unknown category in post id: ${id}`);
return { category, slug: rest.join('/') };
}
export function getPostHref(id: string): string {
const { slug } = parsePostId(id);
return `/posts/${slug}/`;
}
export function getCategoryHref(category: CategoryKey): string {
return `/category/${category}/`;
}
export function getTagHref(tag: string): string {
return `/tags/${tag}/`;
}

parsePostId(post.id) で category と slug に分解し、URL 生成は getPostHref() / getCategoryHref() / getTagHref() に寄せている。記事詳細 URL にはカテゴリを含めないため、カテゴリを変えても記事 URL は変わらない。

年月アーカイブ

src/lib/archive.ts に年月集計のヘルパーを置いている。getArchiveSummaries() は記事一覧から { year, month, count } の配列を新しい順で返し、getPostsByArchive(posts, year, month) で対象月の記事だけを抽出する。日付文字列は YYYY-MM-DD / YYYY.MM.DD / YYYY/MM/DD のいずれも受け付ける。

import { getArchiveSummaries, getPostsByArchive, getArchiveHref } from '@/lib/archive';
const posts = await getCollection('posts');
const summaries = getArchiveSummaries(posts); // [{ year, month, count }, ...]

archive/index.astro ではこの集計結果をそのままリスト表示し、各行から /archive/{year}/{month}/ にリンクしている。月別ページ(archive/[year]/[month]/[...page].astro)では getPostsByArchive() で絞り込んだ記事を Astro の paginate() に渡してページ送りする。リンク先 URL の生成には getArchiveHref(year, month) を使う。

サイト設定

src/config/site.ts にサイト名・キャッチコピー・ページネーション件数・ナビ・コピーライト等をまとめている。テンプレートをカスタマイズする際の入口はここ。

export const siteConfig = {
name: 'lism.blog',
tagline: 'Web開発の学びと記録',
description: 'ブログの説明文をここに入力してください。meta description に使われます。',
lang: 'ja',
theme: { default: 'light' as 'system' | 'light' | 'dark' },
pagination: { postsPerPage: 6 },
header: {
nav: [
{ label: 'Home', href: '/' },
{ label: 'About', href: '/about/' },
{ label: 'Archive', href: '/archive/' },
],
},
ogImage: { type: '1-5', frame: '1', bg: 'fill' },
sns: [
{ label: 'GitHub', icon: 'logo-github', href: 'https://github.com/lism-css/lism-css' },
{ label: 'X', icon: 'logo-x', href: 'https://x.com/lismcss' },
],
footer: {
copyright: '© 2026 Lism CSS',
nav: [
{ label: 'About', href: '/about/' },
{ label: 'Archive', href: '/archive/' },
{ label: 'Privacy Policy', href: '/privacy/' },
{ label: 'Contact', href: '#' },
],
},
} as const;

ヘッダー(モバイルメニュー)で使うナビ項目は siteConfig.header.nav に、フッター用のリンクは siteConfig.footer.nav に、SNS リンクは siteConfig.sns にまとめている。Archive はヘッダー/フッターの両方に並べてある。

ルーティング

src/pages/ 配下のファイル構成は次の通り。

パス内容
[...page].astroトップ(全記事一覧)+ページネーション
category/[category]/[...page].astroカテゴリ別一覧+ページネーション
posts/[...slug].astro記事詳細
tags/index.astroタグ一覧
tags/[tag]/[...page].astroタグ別一覧+ページネーション
archive/index.astro年月アーカイブの目次(年月ごとの件数を一覧)
archive/[year]/[month]/[...page].astro年月別の記事一覧+ページネーション
search.astroPagefind による全文検索 UI
about.astroAbout
privacy.astroPrivacy Policy
404.astro404

ページネーションには Astro の paginate() を使い、1 ページあたりの件数は siteConfig.pagination.postsPerPage(デフォルト 6)を参照する。記事詳細では getStaticPaths 内で記事を日付降順にソートし、prev / next を index で受け渡している。記事詳細 URL は /posts/{slug}/、カテゴリ一覧 URL は /category/{category}/、タグ一覧 URL は /tags/、タグ別 URL は /tags/{tag}/、年月アーカイブ URL は /archive/{year}/{month}/ になる。

全文検索(Pagefind)

build スクリプトは astro build && pagefind --site dist の 2 段構成。Astro のビルド成果物に対して Pagefind がインデックスを生成し、dist/pagefind/ に静的アセットとして配置される。

検索 UI は @pagefind/default-ui を使い、search.astro から呼び出す。クライアントサイドのみで動くため、サーバーや外部 API は不要。開発中はインデックスが存在しないので、検索を試したい場合は nr build && nr preview で確認する。

レイアウト

レイアウトは 3 つ。

  • Layout.astro<html> から <body> までの土台。OGP メタタグ・Web フォント(Gen Interface JP)・カラーテーマ初期化スクリプトを <head> で読み込み、<Container> の中に <Stack min-h="100svh"> で Header / (Breadcrumb) / Main / Footer を縦積みする。
  • ArchiveLayout.astroLayout を基盤に、本文を <Group isWrapper isContainer hasGutter><Stack g="50"> で囲んだ一覧用レイアウト。
  • PageLayout.astroLayout を基盤に、固定ページのタイトルと本文をまとめるレイアウト。本文は <Flow as="article" class="c--pageBody" isWrapper isContainer hasGutter> で囲む。
<Layout title={title} breadcrumb={breadcrumb}>
<Group isWrapper isContainer hasGutter>
<Stack g="50">
<slot />
</Stack>
</Group>
</Layout>

記事詳細のレイアウト構造

記事詳細ページ(posts/[...slug].astro)は、Stack g="50" の中に 3 ブロックを並べる。各ブロックは <Group isWrapper="l" hasGutter> で同じ最大幅を共有する。

<Layout ...>
<Stack g="50">
{/* 1. 記事ヘッダー(Date・Cat・Heading・タグ一覧) */}
<Group as="header" isWrapper="l" hasGutter>...</Group>
{/* 2. 本文 + TOC */}
<Group isWrapper="l" hasGutter>
{/* md 以上: 本文 + サイド目次の 2 カラム。md 未満: 本文のみの 1 カラム */}
<Grid gtc={['1fr', null, 'minmax(0, 1fr) var(--sz--toc)']} g="40">
<Flow as="article" class="c--articleBody">
<Content />
</Flow>
{/* サイド目次(md 以上で表示) */}
<Group as="aside" d={['none', null, 'block']}>
<Group pos="sticky" t="20" z="1">
<TableOfContents toc={toc} labelId="toc-label-side" />
</Group>
</Group>
</Grid>
</Group>
{/* 3. 記事フッター(シェアボタン + 前後記事ナビ) */}
<Group as="footer" isWrapper="l" hasGutter>
<ShareButtons ... />
<ArticleNav ... />
</Group>
{/* md 未満用: 画面下部の固定ボタンから開く目次モーダル */}
<FixedToc>
<TableOfContents toc={toc} labelId="toc-label-modal" />
</FixedToc>
</Stack>
</Layout>

本文と TOC の横並びには Grid を使い、gtc をブレークポイント配列で指定して md(800px)を境に 2 カラム ⇄ 1 カラムを切り替えるmd 以上では本文(minmax(0, 1fr))の右にサイド目次(var(--sz--toc))を並べ、目次は position: sticky でスクロール追従させる。

md 未満ではサイド目次を d={['none', null, 'block']} で非表示にし、代わりに FixedToc コンポーネントを表示する。FixedToc は「画面右下に固定した目次ボタン+目次モーダル」のシェルで、目次の中身は <slot /> で受け取る(モーダルは中央表示・フェードイン)。サイド版と FixedToc 内に TableOfContents を 2 回置くが、labelId を分けて見出し id の重複を避けている。スクロール連動のアクティブ表示は data-toc-link 属性ベースなので、両方の TableOfContents に同時に反映される。モーダル内の目次リンクをタップしたときは、FixedToc のクリック委譲スクリプトが閉じるボタンを呼んでモーダルを閉じる。

--sz--tocglobal.css240px を割り当てている。isWrapper="l" が参照する --sz--l--sz--m + --sz--toc + --s40 で計算しており、TOC があっても本文が中央から大きくずれないように調整している。

ダークモード

siteConfig.theme.default の値('system' / 'light' / 'dark')をベースに、<html data-theme="..."> を切り替える方式。Layout.astro<head> 先頭でちらつき防止用の小さなスクリプトを実行し、localStorage に保存されたユーザー設定を初回描画前に適用する。

コードブロックは expressiveCodethemes: 'github-dark' の単一テーマで運用しているため、サイトのテーマ切り替えに関わらず常に GitHub Dark の配色で表示される。

スタイルの上書き

src/styles/global.css で Lism CSS の CSS 変数を上書きしてサイトのトーンを作っている。

@layer lism-base {
:root {
--base: #fbfaf7;
--base-2: #f3f2ee;
--text: #1a1a1a;
--text-2: #4c4c4c;
--divider: #e8e6e1;
--brand: #c8553d;
--link: #c8553d;
--ff--base: 'Gen Interface JP', 'Hiragino Sans', sans-serif, 'Segoe UI Emoji';
--lts--s: 0.01em;
--lts--base: 0.025em;
--lts--l: 0.075em;
--lts--xl: 0.15em;
--sz--toc: 220px;
--sz--l: calc(var(--sz--m) + var(--sz--toc) + var(--s40));
--headings-fw: 500;
}
}

記事本文のタイポグラフィ(h2 の下線、blockquote の左ボーダーなど)は .c--articleBody 配下の子孫セレクタとして @layer lism-base に書いている。Markdown から生成される要素にはクラスを直接付けられないため、こうした装飾は CSS 側で記述する。