🫑

Astroで記事詳細ページのタイポグラフィを実装する

#Astro #CSS #Typography
目次

ブログサイトを構築するに当たって悩ましい問題の一つである記事ページの見た目をAstro+Tailwind環境でひたすらに楽して構築しようというお話です。

#通常の部分はTailwindCSSの「Typographyプラグイン」で対応

TailwindCSSにはTypographyというプラグインがあり、これを使うとMarkdownの記法にproseというクラスを付与するだけで、簡単に最低限のタイポグラフィのスタイルを当てることが出来ます。

#導入手順

ターミナルでプラグインをインストールします。

Terminal window
bun add @tailwindcss/typography

Note

この記事ではbunを使用しますが、適宜パッケージマネージャーは読み替えてください

次に global.css にインポート文を追加します。

src/styles/global.css
@import "tailwindcss";
@import "@tailwindcss/typography";

#使い方

記事本文のコンテナ要素にproseクラスを当てるだけです。

prose.astro
<article class="prose">
<slot />
</article>

とは言え、本当に最低限なので、ある程度カスタマイズした方が良いと思います。
その場合、prose-[tag]:styleでスタイルを当てましょう。

このサイトのスタイル例
prose.astro
<div
class="prose prose-base md:prose-lg dark:prose-invert prose-headings:font-semibold prose-headings:tracking-tight prose-h2:border-b-2 prose-h2:border-border/60 prose-h2:pb-2 prose-a:text-primary hover:prose-a:text-primary/80 prose-a:transition-colors prose-img:rounded-xl prose-img:border prose-img:border-border/50 prose-hr:border-border/60 prose-hr:my-10 prose-blockquote:border-l-primary/50 prose-blockquote:bg-muted/30 prose-blockquote:py-1 prose-blockquote:not-italic prose-blockquote:text-muted-foreground prose-p:leading-loose max-w-none"
>
<slot />
</div>

#目次(ToC)の導入と使い方

Markdownの見出しから自動的に目次を生成する機能を、Astroの標準機能を使って実装しています。

#導入手順

まず、目次を描画するためのコンポーネントを作成します。

src/components/markdown/TableOfContents.astro
---
import type { MarkdownHeading } from "astro";
export interface Props {
headings: MarkdownHeading[];
}
const { headings } = Astro.props;
// h2とh3のみを抽出
const filteredHeadings = headings.filter(
(heading) => heading.depth > 1 && heading.depth <= 3,
);
---
<details
class="toc border-border/60 bg-muted/20 group mt-8 mb-12 rounded-lg border"
>
<summary
class="text-foreground flex cursor-pointer list-none items-center justify-between px-5 py-4 text-sm font-bold [&::-webkit-details-marker]:hidden"
>
目次
<span
class="text-muted-foreground transition-transform group-open:rotate-180"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"><path d="m6 9 6 6 6-6"></path></svg
>
</span>
</summary>
<div class="border-border/50 border-t px-5 pt-1 pb-5">
<ul class="space-y-3">
{
filteredHeadings.map((heading) => (
<li
class="line-clamp-1"
style={`margin-left: ${(heading.depth - 2) * 1.25}rem;`}
>
<a
href={`#${heading.slug}`}
class="text-muted-foreground hover:text-primary hover:border-primary block border-l-2 border-transparent py-0.5 pl-2 text-sm transition-colors"
>
{heading.text}
</a>
</li>
))
}
</ul>
</div>
</details>

次に、記事を表示するページのコンポーネントで目次を呼び出します。Astroが提供する render() 関数から headings を取得できるため、これをコンポーネントに渡します。

src/pages/note/[id].astro
---
import { getCollection, render } from "astro:content";
import Prose from "../../components/markdown/Prose.astro";
import TableOfContents from "../../components/markdown/TableOfContents.astro";
// ...省略...
// render()からheadingsも一緒に取り出す
const { Content, headings } = await render(note);
---
<!-- 見出しが存在する場合のみ目次を表示 -->{
headings.length > 0 && <TableOfContents headings={headings} />
}
<Prose>
<Content />
</Prose>

#使い方

Markdownファイル内に ## 見出し2### 見出し3 が含まれていれば、記事ページの上部に自動的に目次コンポーネントが表示されます。手動で記事内にタグを書く必要はありません。

#引用とアラート機能(Note, Tipなど)の導入と使い方

GitHubと同じアラート記法を使って、情報を目立たせることができます。

#導入手順

ターミナルでプラグインをインストールします。

Terminal window
bun add remark-github-alerts

次に astro.config.mjs に設定を追加し、global.css でスタイルシートを読み込みます。

astro.config.mjs
import remarkGithubAlerts from "remark-github-alerts";
export default defineConfig({
markdown: {
remarkPlugins: [remarkGithubAlerts],
},
});
src/styles/global.css
@import "remark-github-alerts/styles/github-base.css";
@import "remark-github-alerts/styles/github-colors-light.css";
@import "remark-github-alerts/styles/github-colors-dark-class.css";

#使い方

ブロッククォート > に続けて [!NOTE] などのキーワードを書きます。GithubのREADMEなんかでお馴染みの書き方ですね。

Note

これはNoteブロックです。補足情報などを記載するのに便利です。

Tip

これはTipブロックです。おすすめの方法や役立つ情報を記載します。

Important

これはImportantブロックです。重要な情報を記載します。

Warning

これはWarningブロックです。注意が必要な情報を記載します。

Caution

これはCautionブロックです。危険な操作についての警告を記載します。

#astro-expressive-codeの導入によるコードブロックの装飾

AstroはデフォルトでShikiによるシンタックスハイライトが導入されていますが、それだけではファイル名の表示やコピーマークなどの表示もされないので、あまりに簡素すぎます。
そこで、astro-expressive-code を導入することで、Markdownのコードブロックにファイル名や行ハイライトなどの高度な装飾を簡単に追加できるようになります。

#導入手順

ターミナルでプラグインをインストールします。

Terminal window
bun add astro-expressive-code

次に astro.config.mjs に設定を追加します。

astro.config.mjs
import expressiveCode from "astro-expressive-code";
export default defineConfig({
integrations: [
expressiveCode({
themes: ["github-dark", "github-light"],
// ダークモードの切り替えをCSSクラス(.dark)で行うための設定
themeCssSelector: (theme) =>
theme.name === "github-dark" ? ".dark" : ":root:not(.dark)",
}),
],
});

タイトル指定方法や、ハイライトは以下のように書きます。

```js title=title {2}
function main() {
 console.log(“Hello world!”); //ここがハイライトされる!
}

main()
```

title=filenameでファイル名タイトルを指定でき、{num}でハイライトする行数を指定できます。複数行ハイライトしたい場合は、コンマ区切りで({num,num})、連続した行をハイライトしたい場合はハイフン区切りで({num-num})指定しましょう。

title
function main() {
console.log("Hello world!"); //ここがハイライトされる!
}
main();

#リンクカード(ブログカード)の導入と使い方

Markdown内にURLだけを1行で記述すると、自動的にサムネイルや概要を取得して美しいリンクカード形式で表示されます。

#導入手順

ターミナルでプラグインをインストールします。

Terminal window
bun add remark-link-card-plus

次に astro.config.mjs に設定を追加します。

astro.config.mjs
import remarkLinkCard from "remark-link-card-plus";
export default defineConfig({
markdown: {
remarkPlugins: [[remarkLinkCard, { cache: true }]],
},
});

※本プラグインはスタイル(CSS)を持たないため、global.css などに .remark-link-card-plus__container 等のスタイルを記述する必要があります。

自分は以下のスタイルを拝借させていただきました。

#使い方

単語へのリンク([Astro](https://...))ではなく、URL文字列のみを1行で配置するだけで自動的にカード化されます。

#その他の強力なプラグイン拡張

現在のブログ基盤には、単なる記法だけでなく、読者体験や執筆体験を向上させるための様々な remark / rehype プラグインが組み込まれています。これらはすべて astro.config.mjs 内で一元管理されています。

#1. 読者体験の向上(自動アンカーリンクと外部リンク最適化)

利用プラグイン: rehype-slug, rehype-autolink-headings, rehype-external-links

見出し(H2, H3 等)の左側に自動でクリッカブルな # アイコンを付与し、さらに外部サイトへのリンクには自動的に target="_blank" と「↗」アイコンを付与する実装を行っています。

#設定コード (astro.config.mjs)

import rehypeSlug from "rehype-slug";
import rehypeAutolinkHeadings from "rehype-autolink-headings";
import rehypeExternalLinks from "rehype-external-links";
// ...
rehypePlugins: [
rehypeSlug,
[
rehypeAutolinkHeadings,
{
behavior: "prepend",
properties: { className: ["heading-anchor"], ariaHidden: true, tabIndex: -1 },
content: { type: "text", value: "#" },
},
],
[rehypeExternalLinks, { target: "_blank", rel: ["noopener", "noreferrer"] }],
// ...

#CSSの調整 (src/styles/global.css)

Hタグ自体はクリーンな直デザインを保つため、追加されたアンカー要素(.heading-anchor)のみを左側にはみ出させ、ホバーした時だけ表示されるCSSスタイルを実現しています。

/* Markdown Headers Autolink (ホバーではみ出し) */
.prose h2,
.prose h3,
.prose h4,
.prose h5,
.prose h6 {
@apply relative;
}
.heading-anchor {
@apply text-muted-foreground/40 absolute left-0 pr-1.5 font-normal no-underline opacity-0 transition-opacity;
transform: translateX(-100%);
}
.prose h2:hover .heading-anchor, /* ...各見出しセレクタ... */ {
@apply opacity-100;
}
/* 外部サイトのアイコン自動付与 */
.prose
a[target="_blank"]:not(
.remark-link-card-plus__card,
.remark-link-card-plus__container
) {
@apply inline-flex items-baseline;
}
.prose
a[target="_blank"]:not(
.remark-link-card-plus__card,
.remark-link-card-plus__container
)::after {
content: "↗";
@apply text-muted-foreground ml-0.5 font-sans text-[0.85em];
}

#2. 執筆体験の向上(単純改行の有効化)

利用プラグイン: remark-breaks

Astro側の設定により、通常のエンターキー1回でそのまま改行されるようになっています。文末に半角スペースを2つ入れる必要はありません。

astro.config.mjs
import remarkBreaks from "remark-breaks";
// ...
remarkPlugins: [
remarkBreaks,
// ...

#3. Zenn風のアコーディオン拡張(カスタムディレクティブ)

利用プラグイン: remark-directive, および独自のAST変換スクリプト

:::details[タイトル] という記法を用いることで、Zennのようなアコーディオン要素を生成できるようにしています。標準のディレクティブ機能を拡張し、Astro向けに安全にHTMLの <details> 要素へとコンパイルする独自スクリプトを実装しています。

#カスタムプラグインの実装

unist-util-visit を使い、AST(構文ツリー)をパースしてHTMLへと置換します。

src/plugins/remark-custom-directives.ts
import { visit } from "unist-util-visit";
import { h } from "hastscript";
export function remarkCustomDirectives() {
return (tree: any) => {
visit(tree, "containerDirective", (node: any) => {
if (node.name !== "details") return;
const data = node.data || (node.data = {});
const attributes = node.attributes || {};
data.hName = "details";
data.hProperties = h("details", {
class: "custom-details...", // Tailwindクラス等
...attributes,
}).properties;
// :::details[タイトル] 構文のタイトル部分を抽出
const head = node.children[0];
let titleNodes: any[] = [{ type: "text", value: "詳細" }];
if (head && head.data && head.data.directiveLabel) {
titleNodes = head.children;
node.children.shift();
}
// 展開/開閉用のアイコンやサマリーのHTMLラッパーを構築
const summaryNode = {
/* ... サマリー生成処理 ... */
};
const contentWrapper = {
/* ... コンテンツ生成処理 ... */
};
node.children = [summaryNode, contentWrapper];
});
};
}

そして、この関数を生成時に読み込ませます。

astro.config.mjs
import remarkDirective from "remark-directive";
import { remarkCustomDirectives } from "./src/plugins/remark-custom-directives.ts";
// ...
remarkPlugins: [
remarkDirective, // 1. 記法をディレクティブとしてパース
remarkCustomDirectives, // 2. 独自のAST変換を走らせてHTML属性に追加
// ...

#実用例

ここをクリックで展開

中身です。
太字 など、Markdownの記法も内部で使えます。

#4. YouTubeとX(Twitter)の超高速な自動埋め込み

利用プラグイン: 影響を受けない自作プラグイン (remark-embeds.ts)

Markdown内にURLを単体で記載した際、通常は remark-link-card-plus によってカード化されますが、「YouTube」または「X (Twitter)」のリンクだった場合に限り、自作プラグインが超高速な静的埋め込みへと変換します。 これによりただ表示するだけならLighthouseスコア100を達成できるレベルのパフォーマンスを確保しています。

脳筋ゴリ押し実装
remark-embeds.ts
import { visit } from "unist-util-visit";
import * as fs from "node:fs";
import * as path from "node:path";
const YOUTUBE_REGEX =
/^(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([\w-]{11})(?:\S+)?$/;
const TWITTER_REGEX =
/^(?:https?:\/\/)?(?:www\.)?(?:twitter\.com|x\.com)\/([a-zA-Z0-9_]+)\/status\/([0-9]+)(?:\S+)?$/;
const CACHE_DIR = path.resolve("src", "data", "tweet-cache");
if (!fs.existsSync(CACHE_DIR)) {
fs.mkdirSync(CACHE_DIR, { recursive: true });
}
interface TweetData {
__typename: string;
id_str: string;
text: string;
created_at: string;
favorite_count: number;
conversation_count: number;
user: {
id_str: string;
name: string;
screen_name: string;
profile_image_url_https: string;
is_blue_verified: boolean;
verified?: boolean;
};
mediaDetails?: Array<{
display_url: string;
expanded_url: string;
media_url_https: string;
type: string;
sizes: any;
url: string;
video_info?: {
variants: Array<{ url: string; content_type: string }>;
};
}>;
photos?: Array<{
expandedUrl: string;
url: string;
width: number;
height: number;
}>;
}
function getToken(id: string) {
return ((Number(id) / 1e15) * Math.PI)
.toString(6 ** 2)
.replace(/(0+|\.)/g, "");
}
async function fetchTweetData(tweetId: string): Promise<TweetData | null> {
const cacheFile = path.join(CACHE_DIR, `${tweetId}.json`);
if (fs.existsSync(cacheFile)) {
try {
const cached = JSON.parse(fs.readFileSync(cacheFile, "utf-8"));
if (cached && (cached.__typename === "Tweet" || cached.id_str))
return cached;
} catch (e) {
console.warn(`[remark-embeds] Cache read failed for ${tweetId}:`, e);
}
}
try {
const token = getToken(tweetId);
const url = new URL("https://cdn.syndication.twimg.com/tweet-result");
url.searchParams.set("id", tweetId);
url.searchParams.set("lang", "en");
url.searchParams.set(
"features",
[
"tfw_timeline_list:",
"tfw_follower_count_sunset:true",
"tfw_tweet_edit_backend:on",
"tfw_refsrc_session:on",
"tfw_fosnr_soft_interventions_enabled:on",
"tfw_show_birdwatch_pivots_enabled:on",
"tfw_show_business_verified_badge:on",
"tfw_duplicate_scribes_to_settings:on",
"tfw_use_profile_image_shape_enabled:on",
"tfw_show_blue_verified_badge:on",
"tfw_legacy_timeline_sunset:true",
"tfw_show_gov_verified_badge:on",
"tfw_show_business_affiliate_badge:on",
"tfw_tweet_edit_frontend:on",
].join(";"),
);
url.searchParams.set("token", token);
const res = await fetch(url.toString());
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
const data = await res.json();
if (data && data.__typename === "Tweet") {
fs.writeFileSync(cacheFile, JSON.stringify(data, null, 2), "utf-8");
return data as TweetData;
}
} catch (e) {
console.warn(`[remark-embeds] Tweet fetch failed for ${tweetId}:`, e);
}
return null;
}
const X_LOGO_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 1200 1227" fill="currentColor" aria-hidden="true" class="x-embed-logo-svg"><path d="M714.163 519.284 1160.89 0h-105.86L667.137 450.887 357.328 0H0l468.492 681.821L0 1226.37h105.866l409.625-476.152 327.181 476.152H1200L714.137 519.284zM569.165 687.828l-47.468-67.894-377.686-540.24H309.2l304.797 435.991 47.468 67.894 396.2 566.721H905.539L569.165 687.854z"/></svg>`;
const HEART_SVG = `<svg viewBox="0 0 24 24" aria-hidden="true" width="18.75" height="18.75" fill="currentColor"><g><path d="M16.697 5.5c-1.222-.06-2.679.51-3.89 2.16l-.805 1.09-.806-1.09C9.984 6.01 8.526 5.44 7.304 5.5c-1.243.07-2.349.78-2.91 1.91-.552 1.12-.633 2.78.479 4.82 1.074 1.97 3.257 4.27 7.129 6.61 3.87-2.34 6.052-4.64 7.126-6.61 1.111-2.04 1.03-3.7.477-4.82-.561-1.13-1.666-1.84-2.908-1.91zm4.187 7.69c-1.351 2.48-4.001 5.12-8.379 7.67l-.503.3-.504-.3c-4.379-2.55-7.029-5.19-8.382-7.67-1.36-2.5-1.41-4.86-.514-6.67.887-1.79 2.647-2.91 4.601-3.01 1.651-.083 3.366.56 4.798 2.01 1.429-1.45 3.146-2.09 4.796-2.01 1.954.1 3.714 1.22 4.601 3.01.896 1.81.846 4.17-.514 6.67z"></path></g></svg>`;
const COMMENT_SVG = `<svg viewBox="0 0 24 24" aria-hidden="true" width="18.75" height="18.75" fill="currentColor"><g><path d="M1.751 10c0-4.42 3.584-8 8.005-8h4.366c4.49 0 8.129 3.64 8.129 8.13 0 2.96-1.607 5.68-4.196 7.11l-8.054 4.46v-3.69h-.067c-4.49.1-8.183-3.51-8.183-8.01zm8.005-6c-3.317 0-6.005 2.69-6.005 6 0 3.37 2.77 6.08 6.138 6.01l.351-.01h1.761v2.3l5.087-2.81c1.951-1.08 3.163-3.13 3.163-5.36 0-3.39-2.744-6.13-6.129-6.13H9.756z"></path></g></svg>`;
const SHARE_SVG = `<svg viewBox="0 0 24 24" aria-hidden="true" width="18.75" height="18.75" fill="currentColor"><g><path d="M12 2.59l5.7 5.7-1.41 1.42L13 6.41V16h-2V6.41l-3.3 3.3-1.41-1.42L12 2.59zM21 15l-.02 3.51c0 1.38-1.12 2.49-2.5 2.49H5.5C4.11 21 3 19.88 3 18.5V15h2v3.5c0 .28.22.5.5.5h12.98c.28 0 .5-.22.5-.5L19 15h2z"></path></g></svg>`;
const VERIFIED_BADGE_SVG = `<svg viewBox="0 0 24 24" aria-label="Verified account" width="1.2em" height="1.2em" class="x-embed-badge" style="display:inline-block;vertical-align:middle;margin-left:2px"><g><path d="M22.5 12.5c0-1.58-.875-2.95-2.148-3.6.154-.435.238-.905.238-1.4 0-2.21-1.71-3.998-3.918-3.998-.47 0-.92.084-1.336.25C14.818 2.415 13.51 1.5 12 1.5s-2.816.917-3.337 2.25c-.416-.165-.866-.25-1.336-.25-2.21 0-3.918 1.792-3.918 4 0 .495.084.965.238 1.4-1.273.65-2.148 2.02-2.148 3.6 0 1.46.74 2.743 1.847 3.42-.04.218-.059.444-.059.68 0 2.21 1.71 4 3.918 4 .47 0 .92-.086 1.336-.25.52 1.334 1.819 2.25 3.337 2.25s2.816-.916 3.337-2.25c.416.164.866.25 1.336.25 2.21 0 3.918-1.79 3.918-4 0-.236-.02-.462-.059-.68 1.108-.677 1.847-1.96 1.847-3.42z" fill="#1d9bf0"></path><path d="M10.222 17.5l-3.3-3.3 1.341-1.341 1.96 1.959 5.37-5.37 1.341 1.341-6.711 6.711z" fill="#fff"></path></g></svg>`;
function formatTweetText(tweet: TweetData): string {
let text = tweet.text || "";
if (tweet.mediaDetails) {
for (const media of tweet.mediaDetails) {
if (media.url) {
text = text.replace(media.url, "");
}
}
}
// Url linkification
const urlRegex = /(https?:\/\/[^\s]+)/g;
text = text.replace(urlRegex, '<span class="x-embed-link">$1</span>');
// Newline to br
text = text.replace(/\n/g, "<br/>").trim();
return text;
}
function formatNumber(num: number): string {
if (num === undefined || num === null) return "0";
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + "M";
}
if (num >= 1000) {
return (num / 1000).toFixed(1) + "K";
}
return num.toString();
}
function formatTime(isoString: string): string {
if (!isoString) return "";
const date = new Date(isoString);
const timeOpts: Intl.DateTimeFormatOptions = {
hour: "numeric",
minute: "2-digit",
hour12: true,
};
const dateOpts: Intl.DateTimeFormatOptions = {
month: "short",
day: "numeric",
year: "numeric",
};
const fmtTime = new Intl.DateTimeFormat("en-US", timeOpts).format(date);
const fmtDate = new Intl.DateTimeFormat("en-US", dateOpts).format(date);
return `${fmtTime} · ${fmtDate}`;
}
function buildRichTweetHtml(data: TweetData, originalUrl: string): string {
const textHtml = formatTweetText(data);
const timeString = formatTime(data.created_at);
const finalUrl = originalUrl;
const user = data.user;
const isVerified = user.is_blue_verified || user.verified;
const verifiedHtml = isVerified ? VERIFIED_BADGE_SVG : "";
let mediaHtml = "";
if (data.mediaDetails && data.mediaDetails.length > 0) {
const images = data.mediaDetails
.filter(
(m) =>
m.type === "photo" || m.type === "video" || m.type === "animated_gif",
)
.slice(0, 4);
if (images.length > 0) {
const gridClass = `x-embed-media-grid x-embed-media-${images.length}`;
mediaHtml = `<div class="${gridClass}">`;
for (const img of images) {
const srcUrl = img.media_url_https + "?name=medium";
const videoIcon =
img.type === "video" || img.type === "animated_gif"
? `<div class="x-embed-video-icon"><svg viewBox="0 0 24 24" aria-hidden="true" width="36" height="36" fill="white"><path d="M8 5v14l11-7z"/></svg></div>`
: "";
mediaHtml += `<div class="x-embed-media-item"><img src="${srcUrl}" alt="Tweet media" loading="lazy" decoding="async" />${videoIcon}</div>`;
}
mediaHtml += `</div>`;
}
}
const likes = formatNumber(data.favorite_count);
const replies = formatNumber(data.conversation_count);
return `
<a href="${finalUrl}" target="_blank" rel="noopener noreferrer" class="x-embed-card" style="display:block; text-decoration:none;">
<div class="x-embed-header">
<div class="x-embed-author">
<img src="${user.profile_image_url_https}" alt="${user.name}" class="x-embed-avatar-img" loading="lazy" />
<div class="x-embed-author-info">
<span class="x-embed-name">${user.name}${verifiedHtml}</span>
<span class="x-embed-handle">@${user.screen_name}</span>
</div>
</div>
<div class="x-embed-logo" aria-label="Xで表示">${X_LOGO_SVG}</div>
</div>
<div class="x-embed-body">
<p class="x-embed-text">${textHtml}</p>
${mediaHtml}
</div>
<div class="x-embed-footer">
<div class="x-embed-date">${timeString}</div>
<div class="x-embed-stats">
<div class="x-embed-stat-item reply" aria-label="Replies">
<div class="x-embed-stat-icon">${COMMENT_SVG}</div>
<span>${replies}</span>
</div>
<div class="x-embed-stat-item heart" aria-label="Likes">
<div class="x-embed-stat-icon">${HEART_SVG}</div>
<span>${likes}</span>
</div>
<div class="x-embed-stat-item share" aria-label="Share">
<div class="x-embed-stat-icon">${SHARE_SVG}</div>
</div>
</div>
</div>
</a>`.replace(/\n/g, "");
}
function buildFallbackTweetHtml(url: string): string {
return `<div class="remark-link-card-plus__container twitter-fallback"><a class="remark-link-card-plus__card" href="${url}" target="_blank" rel="noopener noreferrer"><div class="remark-link-card-plus__main"><div class="remark-link-card-plus__title">X (Twitter) Post</div><div class="remark-link-card-plus__description">Xで表示</div><div class="remark-link-card-plus__meta"><span class="remark-link-card-plus__url">${url}</span></div></div></a></div>`;
}
export function remarkEmbeds() {
return async (tree: any) => {
const promises: Promise<void>[] = [];
visit(tree, "paragraph", (node: any) => {
if (node.children.length !== 1) return;
const child = node.children[0];
let url = "";
if (child.type === "text") {
url = child.value.trim();
} else if (child.type === "link") {
url = child.url.trim();
if (
child.children &&
child.children.length === 1 &&
child.children[0].type === "text"
) {
const linkText = child.children[0].value.trim();
if (
!linkText.includes("x.com") &&
!linkText.includes("twitter.com") &&
!linkText.includes("youtube.com") &&
!linkText.includes("youtu.be")
) {
return;
}
}
} else {
return;
}
// ── YouTube ──────────────────────────────────────────────
const ytMatch = url.match(YOUTUBE_REGEX);
if (ytMatch) {
const videoId = ytMatch[1];
const html = `<div class="lite-youtube-wrap"><div class="lite-youtube" data-video-id="${videoId}"><picture style="display:block;position:absolute;inset:0;width:100%;height:100%;"><source type="image/webp" srcset="https://i.ytimg.com/vi_webp/${videoId}/hqdefault.webp"><img src="https://i.ytimg.com/vi/${videoId}/hqdefault.jpg" alt="YouTube video" loading="lazy" decoding="async" style="display:block;width:100%;height:100%;object-fit:cover;"></picture><button class="lite-youtube-btn" aria-label="動画を再生" type="button"><div class="lite-youtube-btn-bg"><svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 24 24" fill="white"><path d="M8 5v14l11-7z"/></svg></div></button></div><script>(function(){var yt=document.currentScript.previousElementSibling;yt.addEventListener('click',function(){var id=yt.dataset.videoId;var ifr=document.createElement('iframe');ifr.src='https://www.youtube-nocookie.com/embed/'+id+'?autoplay=1&rel=0';ifr.allow='accelerometer;autoplay;clipboard-write;encrypted-media;gyroscope;picture-in-picture;web-share';ifr.allowFullscreen=true;ifr.style.cssText='position:absolute;inset:0;width:100%;height:100%;border:0;background:#000';yt.innerHTML='';yt.appendChild(ifr);},{once:true});})();</script></div>`;
node.type = "html";
node.value = html;
delete node.children;
return;
}
// ── X (Twitter) ──────────────────────────────────────────
const xMatch = url.match(TWITTER_REGEX);
if (xMatch) {
const tweetId = xMatch[2];
const embedUrl = url.includes("x.com")
? url.replace("x.com", "twitter.com")
: url;
const promise = fetchTweetData(tweetId).then((data) => {
const cardHtml = data
? buildRichTweetHtml(data, embedUrl)
: buildFallbackTweetHtml(url);
node.type = "html";
node.value = `<div class="x-embed-outer">${cardHtml}</div>`;
delete node.children;
});
promises.push(promise);
}
});
await Promise.all(promises);
};
}
  • YouTube: lite-youtube-embed 方式を採用。初期表示は静的なサムネイル画像とCSSで作られた再生ボタンのみを表示し(16
    )、ユーザーが再生ボタンをクリックした瞬間に初めて <iframe> を生成して読み込みます。
  • X (Twitter): 重い widgets.js のクライアントロードを完全排除。ビルド時に X の非公式API(Syndication API)へアクセスしてメタデータを取得し、ネイティブアプリに忠実なデザインを完全な静的HTMLとして自動生成します。また、JSONファイルによるキャッシュ機構を取り入れ、APIのレート制限に引っかかりづらくなるようにしています。

Note

上記のようなことをしなくても、Intersection Observer APIを用いて、ビューポートに入ったときにロードするようにすれば、初期表示領域にYouTubeやXの埋め込みがない限り、同等の初期レンダリングパフォーマンスを得ることが出来ます。

#実用例

1行だけURLを貼り付けると自動で展開されます。

YouTube video

自作プラグインの対応サービスを拡張することで、他の埋め込みにも対応することができます。

Warning

ただし、Xのポストのリプライやいいね数などは初回ビルド時の数字で固定されてしまうので注意です。

#数式 (TeX / MathJax) の表示

Astro標準のMarkdown機能と連携し、サーバーサイドで数式を安全にパースします。さらにパフォーマンス最適化のため、「数式を直接インラインのSVGに変換して静的HTMLとして出力する(MathJax SVG)」 機構を備えています。これにより、クライアントサイドでの余分なJavaScriptの実行や、数式用Webフォントを取得するためのネットワーク通信は一切発生しません。

#実装・設定コードの例

#Astro設定ファイルへの組み込み

remark-mathrehype-mathjax/svg を使って、Markdownでの数式をビルド時にSVGへと静的生成します。

Terminal window
bun add remark-math rehype-mathjax
astro.config.mjs
import remarkMath from "remark-math";
import rehypeMathjax from "rehype-mathjax/svg";
export default defineConfig({
markdown: {
remarkPlugins: [remarkMath],
rehypePlugins: [rehypeMathjax],
},
});

#使い方

インライン数式は $ で囲み、ブロック数式は $$ で囲みます。

インライン数式の例:アインシュタインの有名な方程式 は質量とエネルギーの等価性を示しています。

ブロック数式の例:

#図表 (Mermaid) の表示

mermaid 言語でコードブロックを記述するだけで図表が作成できます。パフォーマンスと開発体験を両立するため、Astroの裏側で以下の2つの描画モードを自動的に切り替える仕組みを構築しました。

  • 開発環境 (Dev): クライアント側で公式の Mermaid.js を動的に読み込み、リアルタイムでプレビュー・レンダリングします。
  • 本番環境 (Build): ビルド時にカスタムプラグイン(src/plugins/remark-mermaid.ts)が働き、Kroki API を利用して完全に静的なSVG画像へ変換・埋め込みます。これにより、本番サイトではJavaScriptの実行負荷やライブラリの読み込みが一切発生しない、超高速な表示を実現しています。

Note

なぜKroki APIを採用したのか?
本サイトのホスティング先のビルド環境(Cloudflare Pages / Workers)では、Puppeteer等のブラウザ(Chromium)を必要とするツールがそのままでは動作しません。そのため、rehype-mermaid@mermaid-js/mermaid-cli など、ローカルで完全なSVGを事前生成する一般的なライブラリは採用できませんでした。
また、ブラウザ非依存で高速にSVG事前生成が可能な beautiful-mermaid などの軽量ライブラリも検討しましたが、対応している図の種類が一部のみであり、公式のMermaid構文との完全な互換性がないため不採用としました。
その結果、外部APIへの依存は発生しますが、完全な構文サポートを維持しつつビルド環境を選ばない「Kroki APIへのビルド時取得(本番時のみ)」というアプローチをとっています。

#実装・設定コードの例

この仕組みは、以下の3つのファイル設定によって実現されています。

#1. カスタムプラグインの作成

Nodeの標準ライブラリ(node:zlib)を利用してコードをエンコードし、Kroki APIへリクエストを送るRemarkプラグインを作成します。

src/plugins/remark-mermaid.ts
import { visit } from "unist-util-visit";
import { deflateSync } from "node:zlib";
import { Buffer } from "node:buffer";
// 現在の環境が開発モードか判定
const isDev = import.meta.env.DEV;
function encodeKroki(source: string) {
return Buffer.from(deflateSync(source, { level: 9 })).toString("base64url");
}
export function remarkMermaidSsr() {
return async (tree: any) => {
const promises: Promise<void>[] = [];
visit(tree, "code", (node: any, index: number | undefined, parent: any) => {
if (node.lang === "mermaid") {
if (isDev) {
// Dev時はクライアントで描画するためのプレーンなタグを出力
const safeValue = node.value
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
if (parent && typeof index === "number") {
parent.children[index] = {
type: "html",
value: `<pre class="mermaid my-8 flex text-foreground justify-center">${safeValue}</pre>`,
};
}
} else {
// 本番時はKroki APIで静的なSVGに変換
const encoded = encodeKroki(node.value);
const url = `https://kroki.io/mermaid/svg/${encoded}`;
promises.push(
fetch(url)
.then((res) => res.text())
.then((svg) => {
if (parent && typeof index === "number") {
parent.children[index] = {
type: "html",
value: `<div class="mermaid-diagram my-8 flex justify-center [&>svg]:max-w-full [&>svg]:h-auto">${svg}</div>`,
};
}
})
.catch((err) => console.error("Mermaid SSR error:", err)),
);
}
}
});
await Promise.all(promises);
};
}

#2. Astro設定ファイルへの組み込み

作成したプラグインを astro.config.mjs でインポートし、AstroのMarkdownプラグインの初期段階(他のプラグインに干渉される前)で実行させます。

astro.config.mjs
// 他のインポート...
import { remarkMermaidSsr } from "./src/plugins/remark-mermaid.ts";
export default defineConfig({
markdown: {
remarkPlugins: [
remarkMermaidSsr,
// その他のプラグイン...
],
},

#3. 記事ページへの動的スクリプト追加

Astroの強力なバンドル機能を避け(is:inline)、Markdown内に mermaid ブロックが存在する「開発環境」のときだけ、ブラウザでレンダリングさせるためのJSタグを差し込みます。

src/pages/note/[id].astro
// Markdownを描画するためのデータ取得
const { Content } = await render(note);
// mermaid記法が含まれているか、かつ現在がDev環境かチェック
const hasMermaid = note.body && note.body.includes('```mermaid');
const isDev = import.meta.env.DEV;
---
{isDev && hasMermaid && (
<script type="module" slot="head" is:inline>
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs';
mermaid.initialize({ startOnLoad: true, theme: 'base' });
</script>
)}
<article>...

これらの連携により、以下のように実際に図表が描画されます。

Dev

Prod/Build

Mermaid Code

環境判定

Client-side Mermaid.js

Kroki API SSR

Preview

Static SVG Embed

これで、十分なタイポグラフィを確保できていると思います。お疲れ様でした。

タイポグラフィといいつつ、後半はただただ静的にしてパフォーマンス最適化を行っているだけでしたね。🤔