ブログ記事からスライドを作れるようにしました
よろしくお願いします。こんにちはinfixerといいます。都内でフロントエンドエンジニアとして働いています。インターネットではこのあたり(スライドの右下の自己紹介エリア参照)に住んでいるので良かったらのぞいてみてください。
今日はこのブログに入れたスライドの仕組みについて話します。今投影しているこのスライドもブログの記事から作ったものです。使い方を紹介したあと、記事をどうやってスライドにしているのかを紹介します。
このブログの記事の一番上の日付の右側に「スライドを見る」というリンクがありますのでそこから確認できます。
作ったもの
ブログの記事に設定を少し足すと、その記事からスライドを作る仕組みです。
- 記事のfrontmatterに
slidesを足すと、/slides/記事のidにスライドができる - 記事の段落はスピーカーノートになる
- リスト・コード・画像・表はスライドに出る
これが表紙です。記事のタイトルと日付が入り、右下に自己紹介が出ます。

作ろうと思った理由
今年、LTや登壇などを機会をいくつか行なってきました。その中で、自分のスタイルとして話したいアウトラインを先に書き、スライドを作りながらある程度できたらスピーカーノートを書くみたいなことをしていました。しかし時間がかかるなと感じていて最初から話したい内容、つまりスピーカーノートをブログにしてしまい、その内容からいい感じにスライドを作ってしまえないかと思い作りました。
もちろんスライドの表現力やスライド芸みたいなもので見やすくしないといけないこともあるかと思いますが、LTなどではスライドでは説明できない補足情報などを後で、ブログにするケースもあったため今回LT用途向けに作ってみたものです。(もちろん今回作ったものは全てAI製です。技術スタックとか設計などは人間がある程度行いました。)
記事を先に書いて、スライドはあとから作る
記事の段落がそのままスピーカーノートになるので、発表が終わってから話した内容を記事に書き起こす必要がありません。
書き方のルール
段落はスライドには出ないので、話す内容は段落に書き、見せたいものはリストや画像にします。表示するものがない見出しでは、段落の中の太字を大きく出します。さっきの「記事を先に書いて、スライドはあとから作る」のスライドも、この太字からできたものです。
記事のどこがスライドに投影されるかは、次のように決めています。
| 記事 | スライド |
|---|---|
##の見出し | 章の区切り |
###の見出し | 1枚のスライド |
--- | 同じ見出しのままスライドを分ける |
| 段落 | スピーカーノート(スライドには出ない) |
| リスト・コード・画像・表 | スライドに出る |
| 段落の中の太字 | 表示するものがないときに大きく出る |
この表がスライドになると、こうなります。もう少し改善の余地はありますが、一旦最低限のことはできます。

全体の流れ
ここからは仕組みの話です。処理はビルドのときに動くものと、ブラウザで動くものに分かれています。
- ビルド時:記事の本文をMarkdownの構文木にして、スライドのHTMLを作る
- ブラウザ描画時: スライドモードに切り替え、発表者ビューとの同期
ビルドでは、AstroのgetStaticPathsでfrontmatterにslidesがある記事だけを集めて、/slides/記事のidのページを事前生成しています。Markdownの解析とコードの色付けはビルドのときにだけ動くので、そのためのライブラリはブラウザでは使っていません。ブラウザに送るJavaScriptは、スライドを操作する処理とスライドに内容が収まらない時のはみ出しを調整する処理だけです。
記事をスライドに分ける
Markdownを構文木にする
まずmdast-util-from-markdownで、記事の本文をmdastというMarkdownの構文木にします[参照1]。表を読めるようにGitHub Flavored Markdownの拡張を入れていて、.mdxの記事ではMDXの拡張も入れます。
function parse(body: string, mdx: boolean): Root {
return fromMarkdown(body, {
extensions: mdx ? [gfm(), mdxjs()] : [gfm()],
mdastExtensions: mdx
? [gfmFromMarkdown(), mdxFromMarkdown()]
: [gfmFromMarkdown()],
});
}見出しで区切る
構文木のいちばん上の要素を、先頭から順に見ていきます。見出しと区切り線が来たら、そこでスライドを分けます。
##が来たら、章の名前を覚えて新しいスライドを始める###が来たら、章の名前を小さく添えたスライドを始める---が来たら、同じ見出しのまま次のスライドにする
##のすぐあとに###が続いたときは、その##を章扉のスライドにします。この「記事をスライドに分ける」の章も、###が続いているので章扉になっています。
短い例で、構文木の要素とスライドの対応を図にすると次のようになります。段落はスライドには出ず、同じ色のスライドのスピーカーノートになります。

段落とそれ以外を振り分ける
段落はスピーカーノートに入れ、それ以外のリスト・コード・画像・表・引用はスライドに出します。段落の形をしていても、画像だけの段落や、リンクカードだけの段落、HTMLの要素だけの段落はスライドに出します。<Cite />以外の大文字のコンポーネントはスライドでは描画できないので出しません。
段落はそのあとに出てくるリストや画像と同じスライドのスピーカーノートにしています。リストや画像の前に説明を書くことが多いからです。
段落は、そのあとに出てくるリストや画像のスピーカーノートになる
この記事を書いていて、表や画像のあとに続けて書いた説明が、その次のスライドのスピーカーノートになってしまう箇所がありました。説明をリストや画像の前に書くと、意図したスライドのスピーカーノートになります。
1枚に載せる量を決める
コード・画像・表・埋め込みは場所を取るので、1枚に1つまでにしています。リストもこれらと同じスライドには載せません。載せられないものが来たら次のスライドに送り、直前の段落もそちらのスピーカーノートにします。
const HEAVY: ReadonlySet<VisibleKind> = new Set([
'code', 'image', 'table', 'embed', 'tweet', 'html',
]);
function conflicts(visible: VisibleBlock[], kind: VisibleKind): boolean {
const hasHeavy = visible.some((v) => HEAVY.has(v.kind));
const hasList = visible.some((v) => v.kind === 'list');
if (HEAVY.has(kind)) return hasHeavy || hasList;
if (kind === 'list') return hasHeavy;
return false;
}レイアウトを選ぶ
スライドのレイアウトは載っているものの種類で選んでいます。
| 載っているもの | レイアウト |
|---|---|
| リストだけ | 箇条書き |
| コードだけ | コード |
| 画像だけ | 画像 |
| 表だけ | 表 |
| 種類が混ざっている | 汎用 |
| 段落しかない | 段落中の太字を大きく出す |
太字を大きく出すときは、最大3つまでにしています。
HTMLにする
コードの色付け
コードの色付けにはShikiを使っています。このサイトの事前生成はCloudflare上で動いていて、WASMが使えなさそうでした。そのためWASMの正規表現エンジンではなく素のJavaScriptの正規表現を使っています[参照2]。
highlighter ??= createHighlighter({
themes: [CODE_THEME],
langs: [],
engine: createJavaScriptRegexEngine({ forgiving: true }),
});テーマはgithub-dark-defaultにしました。ブログで使っているgithub-darkは、コメントの文字色と背景のコントラスト比が3.05:1しかありません。github-dark-defaultなら6.5:1になります。
参照の番号
このブログでは、記事の最後に参照の一覧を出し、本文から番号で参照しています。ブログではAstroのコンポーネントが番号を振っていますが、スライドは構文木から作るので、構文木の段階で<Cite />を番号のリンクに置き換えています。
今回mdast-util-to-hastというライブラリを使いました。ノードのdata.hNameを見てHTMLの要素名を決めます[参照3]。そこで強調のノードにhName: 'sup'を付けて、<sup>として出しています。data.citationに入れた番号とタイトルとURLは、スライドの下に出す出典を集めるためのものです。
return {
type: 'emphasis',
data: {
hName: 'sup',
hProperties: { className: ['cite'] },
citation,
},
children: [
{
type: 'link',
url: reference.url,
title: citation.title,
children: [{ type: 'text', value: `[${citation.n}]` }],
},
],
} as RootContent;最後のスライドの前には参照の一覧のスライドが入ります。

スライドの表示
16:9を保つ
スライドはどの画面でも16:9で同じ見た目になるようにしています。スライドを並べる外枠をグリッドにして、すべてのスライドを同じマスに重ねて中央に置く作りです。スライドの幅は枠に収まる最大の16:9になるように、コンテナ単位で決めています[参照4]。
.deck {
position: fixed;
inset: 0;
display: grid;
place-items: center;
container-type: size;
}
.slide {
grid-area: 1 / 1;
inline-size: min(100cqi, 100cqb * 16 / 9);
aspect-ratio: 16 / 9;
container-type: size;
overflow: clip;
}cqiは外枠の横幅、cqbは縦の高さを基準にした単位です。横幅いっぱいにした場合と、高さいっぱいにした場合の幅を比べて、小さいほうをmin()で選んでいます。aspect-ratioだけでは、縦が足りないときに横幅が縮まないためです。スライドの中はスクロールさせないので、はみ出した部分はスクロール領域を作らないoverflow: clipで切っています。
スライドの中の文字や余白の大きさも、スライド自体をコンテナにしたcqwとcqhの値です。文字の大きさにはすべてvar(--fit, 1)を掛けています。この値は、あとで説明するはみ出しの調整で使います。
デザインをCSS変数で切り替える
デザインは5つ用意しました。
| 名前 | 動き |
|---|---|
| simple | アニメーションなし |
| keynote | ふわっと拡大して出る |
| paper | 横に流れる |
| pop | 弾んで出る |
| terminal | 見出しを打ち込むように出る |
デザインごとの違いはhtml要素のdata-themeに合わせて切り替えるCSS変数になっています。スライドの遷移は--slide-fromと--slide-to、要素の出方は--enterにアニメーションの名前を入れて決めています。要素は--iの順に少しずつ遅らせて出します。
発表時に好きなテーマにいつでも変更することができます。ほとんどAIが考えた内容をそのまま採用しているので、例えばpopとかはスライドのアニメーションが激しくて面白いです。
.slide.is-active .anim,
.slide.is-active .block-list li {
animation: var(--enter) var(--dur) var(--ease) backwards;
animation-delay: calc(var(--delay) + var(--i, 0) * var(--stagger));
}simpleは--enterをnoneにして、遷移もなくしています。OSで動きを減らす設定にしている人には、どのデザインでも動くアニメーションを止めます。
terminal以外はこのブログと同じ配色です。ページの先頭で小さなスクリプトがlocalStorageのthemeを読み、なければOSの設定を見て、ダークモードにするかを決めています。

画面の右下のメニューから、発表中でもデザインを選び直せます。選んだデザインはURLの?theme=に残るので、そのURLを共有すれば同じデザインで開けます。

表示中のスライドだけ操作できるようにする
スライドはすべて同じページの中にあり、表示していないスライドはvisibility: hiddenで隠しています。ただ、切り替えのアニメーションの間は、前のスライドもまだ見えています。その間に前のスライドへフォーカスが移らないように、表示していないスライドにはinert属性[参照5]とaria-hiddenを付けています。
YouTubeなどの埋め込みはURLをdata-srcに入れておき、表示しているスライドの分だけsrcに入れています。スライドを離れたらsrcを外すので、動画が裏で再生され続けることもありません。
発表者ビューとの同期
Pキーを押すと、発表者ビューが別のウィンドウで開きます。左に今のスライドと次のスライド、右にスピーカーノートが出る画面です。スピーカーノートは記事の段落そのものなので、発表中はここを読みます。タイマーも付けました。
発表者ビューは同じページを?presenter付きで開いたものです。2つのウィンドウはBroadcastChannel[参照6]でつないでいて、どちらかでスライドを進めると、今の位置をもう一方に送ります。デザインを選び直したときも同じように送るので、サーバーは使っていません。

ほかにも次のことができます。
Nでスライドの下にスピーカーノートを出すFで全画面にする- 印刷するとPDFにできる
はみ出し対策
記事をそのままスライドにすると、文字が多いスライドは枠からはみ出します。そこで次の3つを入れました。
- リストとコードや画像は、同じスライドに載せない
- はみ出すスライドだけ、表示するときに文字を小さくする
- ビルドしたスライドをブラウザで開いて、はみ出していないかをテストする
1つ目はさっきの「1枚に載せる量を決める」で説明したものです。ここでは2つ目と3つ目を説明します。
表示するときに文字を小さくする
ブラウザでスライドを開いたら、1枚ずつはみ出していないかをみます。はみ出していたら--fitを小さくして、収まる最大の値を二分探索で探します。
if (fitsAt(1)) return { fit: 1, overflow: false };
if (!fitsAt(MIN_FIT)) return { fit: MIN_FIT, overflow: true };
let low = MIN_FIT;
let high = 1;
while (high - low > PRECISION) {
const mid = (low + high) / 2;
if (fitsAt(mid)) low = mid;
else high = mid;
}
return { fit: Math.floor(low * 100) / 100, overflow: false };文字を小さくするのは60%までです。それでも収まらないスライドにはdata-overflowを付けて、コンソールに警告を出すようにしています。そのときは記事に---を入れてスライドを分けています。
スライドを複製して本物の隣に置き、複製のほうと見比べています。表示中のスライドはアニメーションで要素が動いていて、そのままでは正しく測れません。アニメーションを止めたりやり直したりすると見ている人に分かってしまうので、アニメーションを止めた複製を見えない状態で置いて測っています。
書体の読み込みが終わったとき、画面の大きさが変わったとき、画像を読み込んだとき、デザインを選び直したときにも測り直します。
はみ出しの判定
はみ出しているかどうかは、次の2つで判定しています。
- スライドの本文の領域や、その中のブロックから、子の要素の矩形が出ていないか
- コードのようにスクロールする要素で、中身が要素より大きくなっていないか
文字の形が行の高さより少しはみ出すことはよくあるので、文字ではなく要素の矩形で見るようにしました。
まとめ
- 記事を書けば、スライドもできる
- 段落はスピーカーノート、リストや画像はスライドになる
- 分け方とレイアウトはビルドのときに決め、はみ出しはブラウザで調整する
- はみ出しはテストで確かめている
一旦作ってはみたものの、まだまだ改善の余地はありそうだなと思っています。レイアウトもちょっといけていない部分があるのでちょいちょい変更しようかなと思いました。また、スライドを生成する処理なんかは今までやったことなかったので、AIが作ったものをみて知らないライブラリを学べたり作り方をざっくり把握することができたのはいい発見でした。
参照
- syntax-tree/mdast-util-from-markdown本文の参照1へ戻る
Markdownをmdast(Markdownの構文木)にするライブラリ
- Shiki: Regex Engines本文の参照2へ戻る
Shikiの正規表現エンジン(WASMとJavaScript)
- syntax-tree/mdast-util-to-hast本文の参照3へ戻る
mdastをHTMLの構文木(hast)にするライブラリ。data.hNameで要素名を変えられる
- MDN: Container size and style queries本文の参照4へ戻る
コンテナ単位(cqw / cqh)の説明
- MDN: inert本文の参照5へ戻る
- MDN: Broadcast Channel API本文の参照6へ戻る
