経緯
fuwariテーマの導入、開発環境の仕様については以下を参照。
remarkによる独自構文等の技術的な補足
remarkやらunifiedがわかってる方は飛ばしてもらってOK。本題はこちら
VSCode上で記事執筆をMarkdownで行うにあたり、独自の統一的なスタイルやNotionレイアウトの再現をするための技術。(というかMarkdownの構文を拡張するライブラリ)
SSGを始めて触れる者にとっては、固有名詞が頻出しまくり混乱を招く要因のひとつなので、自分の理解の整理も兼ねて補足しておく。
remark / rehype / unified
- remark:Markdown → AST
- rehype:HTML → AST
- unified:変換処理をパイプライン化する仕組み
AST(抽象構文木)
- Markdown や HTML を「ツリー構造のJSONデータ」にしたもの
- テキストではなく「ノードの集合」として扱う
- ノードを走査(visit)して
- 追加
- 削除
- 置換
ができる
- 最終的に AST → HTML へ変換される
unified
- AST処理の“流れ”を作るエンジン
.use()でプラグインを連結- 流れの基本構造:
- parse(Markdown → AST)※remark
- transform(AST加工)※プラグイン
- stringify(AST → HTML)
- remark / rehype は unified 上で動く仕組み
remark / rehype の位置づけ
- remark:Markdown系AST(mdast)を扱う
- rehype:HTML系AST(hast)を扱う
- 両者は unified 上で接続できる
Astroとの関係
- Astroは
src/配下の.mdに対して remark / rehype が組み込み済み astro.config.jsのmarkdownにプラグインを追加すれば独自構文対応可能
記事(ヘッドレスCMS)とフロントエンド(remark)の責務、思想
- Markdownには「構造のみ」書く
- デザイン・レイアウトはプラグイン側で実装
- 構造とデザインを分離する目的は、再利用性・移植性を高めることが主
remark directive
- 定型的な区画を定義するための構文を提供するライブラリ
:::modなどで囲むと、その子要素全てに属性・クラスを付与できる
Tailwind CSS
- ユーティリティCSS集
- レスポンシブ対応が容易
- remarkで生成するコンポーネントのデザイン実装と相性が良い
まあいろいろ書いてはいるが、自作の構文の作り方(おまじない)だけわかっていればASTとかはあんまり考えなくてもいい。今は生成AIでなんとかできるし
最低限Tailwind CSSとremark directiveは相性がいいということだけ覚えておくだけでもいい。
具体的にはTailwind CSSはhtmlのclass属性でスタイルを指定でき、remark directiveは構文レベルでclass属性を指定できる。
つまり、markdown上でスタイルが指定できるようになるのがメリット。
自作構文の例はこちら
fuwariテーマでカスタマイズしたこと
URLが変わるページのリダイレクト設定
アスノブでやっていたのが、規約とかプロフィールのページなども投稿として作成し、ヘッダーにリンクを固定表示させて固定ページに見せかけるということをしていた。
fuwariでは純粋な固定ページが用意できるので、posts/配下のurlからリダイレクトさせるようにした。
astro.config.jsのdefineConfig内に以下のように設定している。
redirects: { "/posts/inquiry/": "/inquiry/",},
notionレイアウト再現用ディレクティブ構文の作成
画像や文章を縦列で分割するために、flexとレスポンシブを適用したディレクティブを作成。
tailwind cssと組み合わせることで比較的容易にカスタマイズできる。
import { visit } from 'unist-util-visit'
export function myRemarkContainer() { return (tree: any) => { visit(tree, (node) => { if ( node.type !== 'leafDirective' && node.type !== 'containerDirective' ) { return }
if (node.type === 'containerDirective' || node.type === 'leafDirective') { switch (node.name) { case 'fl': flex(node); return;
case 'fli': flexItem(node); return;
default: break; }
} }) }}
function flex(node:any) { const attrs = node.attributes || {} node.name = 'div' node.attributes = { class: [`md:flex`, 'm-auto', 'flex-wrap', 'gap-4', 'sm:flex-nowrap', 'justify-center-safe', 'content-center', attrs.class ] }}
function flexItem(node:any) { const attrs = node.attributes || {} node.name = 'div' node.attributes = { class: [attrs.class, 'w-full' ] }}markdownでの使用例
::::fl:::fli{.border .bg-red-200}左側(スマホ表示だと上)::::::fli{.border .bg-green-200}右側(スマホ表示だと下):::::::描画結果
左側(スマホ表示だと上)
右側(スマホ表示だと下)
「::::fl{.items-center}」 などと指定すると、上下中央揃えできたりする。
記事フォルダ分け
デフォルトではsrc/content/posts直下にmdファイルなどが置かれている。
slug名のフォルダにmd、画像ファイルを入れ込むが、年のフォルダも作る形にした。
特に設定変更はしなくて良い。
- src
- content
- posts
- 2026
- slug名フォルダ
- 記事.md
- 画像.jpg
- slug名フォルダ
- 2026
- posts
- content
この構造の目的は、記事数が増えていったときの管理のしやすさと、年ごとのアーカイブ的な意味合いを持たせるため。
tocの追加
目次はデフォルトで右サイドに表示されるのだが、スマホ表示では目次が完全に表示されなくなる。
せめてどこかしらに表示させたかったので、本文上部にtocコンポーネントを追加した。


テーブル表示、文字の色つけ構文
主にcssをアスノブから移植し、スクロール、ヘッダの色をつけるようにした。
markdownで行ヘッダを実現するために、remark、rehypeでtdタグをthタグに変換する構文を作った。
他にも背景色、文字色をtdタグなどに移すためのremarkも作った。
import { visit } from 'unist-util-visit'
export function myRemarkText() { return (tree: any) => { visit(tree, (node, index, parent) => { if ( node.type !== 'textDirective' && node.type !== 'leafDirective' && node.type !== 'containerDirective' ) { return }
const attrs = node.attributes || {}
if (node.type === 'textDirective') { if(node.name === 'th'){ tableHeader(parent, index, node, attrs) } // line marker if(node.name.startsWith('lmk')) { lineMaker(node, parent, index, attrs) } // color text if (node.name.startsWith('ct')) { colorText(node, parent, index, attrs) }
} }) }}function tableHeader(parent: any, index: number | undefined, node: any, attrs: any) { parent.children.splice(index, 1, ...node.children) parent.data = { hName: 'th' } parent.data.hProperties = { ...attrs, }}
function colorText(node: any, parent: any, index: number, attrs: any) { const color = node.name.replace('ct', '').trim() if (parent.type === 'tableCell') { parent.children.splice(index, 1, ...node.children) parent.data = { hProperties: { class: [`ld-text-${color}`, attrs.class ?? ''], } } } else { node.name = 'span' node.attributes = { class: [`ld-text-${color}`, attrs.class ?? ''], } }}
function lineMaker(node: any, parent: any, index: number | undefined, attrs: any) { if (node.name === 'lmk') { parent.children.splice(index, 1, ...node.children) parent.data = { hProperties: { ...attrs, } } } else { const color = node.name.replace('lmk', '').trim() if (parent.type === 'tableCell') { parent.children.splice(index, 1, ...node.children) parent.data = { hProperties: { class: [`ld-text-${color}`, `ld-bg-${color}`, attrs.class ?? ''], } } } else { node.name = 'span' node.attributes = { class: [`ld-text-${color}`, `ld-bg-${color}`, attrs.class ?? ''], }
} }}mermaidの描画
fuwariではコードハイライトは対応しているが、mermaidのような図表のコードを描画する機能は標準搭載していない。
astro-mermaidを導入したが、expressive codeの設定と一部折り合いが悪く、少々修正。
ダークモード対応
fuwariで実装されている機能。
ナビゲーションバーのサイト名がテーマカラーと黒で固定で、モードによっては見づらくなるので無効にした。
<div class="flex flex-row text-[var(--primary)] items-center text-md"><span class="text-black">{siteConfig.title}</span><div class="flex flex-row items-center text-md"><span class="">{siteConfig.title}</span>装飾用のremarkもライトモード、ダークモードで色を変えるようcssを調整。
文字色、背景色それぞれで色ごとにcssをtailwind cssの@applyで定義。
.ld-text-red { @apply text-red-700 dark:text-red-100;}.ld-bg-red { @apply bg-red-100 dark:bg-red-700;}上記は赤色のサンプルだが、実際には各色ごとに定義している。(生成AIの使い所)
ライトモード、ダークモードの切り替えには、ローカルストレージで選択状況を保存する実装となっている。
故に規約にローカルストレージを利用することも追記した。
liの階層別デザイン
全部黒ポチになっている。これはtailwindのプラグインである「typography」の設定が影響している。
tailwind.config.tsに変更を加え、ブラウザ標準のものに指定して解決
module.exports = {fontFamily: { sans: ["Roboto", "sans-serif", ...defaultTheme.fontFamily.sans],}, typography: { DEFAULT: { css: { 'ul, ol': { listStyle: 'revert', paddingLeft: 'revert', marginLeft: 'revert', }, li: { margin: 'revert', }, 'ul > li::marker': { color: 'inherit', fontWeight: 'inherit', }, 'ol > li::marker': { color: 'inherit', fontWeight: 'inherit', }, }, }, },},さらに色についても変更。
デフォルトではテーマカラーだったので、表示モードを考慮しボタンの色に変更。
ul, ol { li::marker { @apply text-[var(--primary)]; @apply text-[var(--btn-content)]; }}リンクカードの導入
remark-link-card-plusを導入。
markdownにurlのみの行があると、リンクカードのUIに変換してくれる。
ビルド時にサムネイルをpublicフォルダ配下にキャッシュする仕様だが、画像の圧縮等はしてくれないので必要に応じて手動で圧縮している。
swupの無効化
静的サイトのページ間の遷移をSPA風にしてくれるフレームワークなのだが、色々問題があるので消した。
代償としてspa風の挙動ではなくなったので、全てのuiがページ遷移のたびに再表示されるようになった。
問題点
- md内のアフィリンク用のscriptが、サイト内のページ遷移時に描画されない
もしもアフィリエイトのかんたんリンクで作ったスクリプトを記事のmdファイルに貼り付けて、トップページなどからリンクのあるページに遷移したとき、アフィリエイトの部品が描画されない。
直リンクでの表示やリロードでは描画される。 - swupのreloadScriptsやhookなどを試してみたが状況が変わらない
swupの導入や設定自体はマニュアルが充実しているものの、挙動自体はかなり怪しい。
読み込み時のアニメーションの無効
swupに付随して、cssでパーツの表示時にトランジションがかかるよう設定されている。
swupを無効にすると常に全てのパーツを再描画することになり、ページ遷移するたびにトランジションが発生し見づらくなったため、cssを削除して無効化した。
.onload-animation { opacity: 0; animation: 300ms fade-in-up; animation-fill-mode: forwards;}lightboxの設定変更
swupのhookに設定されていたlightboxを、swupがなくても動くよう調整。
if (window.swup) { setup()} else { createPhotoSwipe() document.addEventListener("swup:enable", setup)}ただこれだけでは不十分で、lightboxの対象がもしもアフィリエイトの商品の画像や、リンクカードのサムネにも適用されてしまう状態となる。
ここらへんの制御はLayout.astroのlightbox設定内の、cssセレクタを修正して対応した。
function createPhotoSwipe() {lightbox = new PhotoSwipeLightbox({ gallery: ".custom-md img, #post-cover img", gallery: ".custom-md img:not(.easyLink-box img):not(.remark-link-card-plus__thumbnail img):not(.remark-link-card-plus__main img), #post-cover img", pswpModule: () => pswp, closeSVG: '<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#ffffff"><path d="M480-424 284-228q-11 11-28 11t-28-11q-11-11-11-28t11-28l196-196-196-196q-11-11-11-28t11-28q11-11 28-11t28 11l196 196 196-196q11-11 28-11t28 11q11 11 11 28t-11 28L536-480l196 196q11 11 11 28t-11 28q-11 11-28 11t-28-11L480-424Z"/></svg>', zoomSVG: '<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#ffffff"><path d="M340-540h-40q-17 0-28.5-11.5T260-580q0-17 11.5-28.5T300-620h40v-40q0-17 11.5-28.5T380-700q17 0 28.5 11.5T420-660v40h40q17 0 28.5 11.5T500-580q0 17-11.5 28.5T460-540h-40v40q0 17-11.5 28.5T380-460q-17 0-28.5-11.5T340-500v-40Zm40 220q-109 0-184.5-75.5T120-580q0-109 75.5-184.5T380-840q109 0 184.5 75.5T640-580q0 44-14 83t-38 69l224 224q11 11 11 28t-11 28q-11 11-28 11t-28-11L532-372q-30 24-69 38t-83 14Zm0-80q75 0 127.5-52.5T560-580q0-75-52.5-127.5T380-760q-75 0-127.5 52.5T200-580q0 75 52.5 127.5T380-400Z"/></svg>', padding: { top: 20, bottom: 20, left: 20, right: 20 }, wheelToZoom: true, arrowPrev: false, arrowNext: false, imageClickAction: 'close', tapAction: 'close', doubleTapAction: 'zoom',})マウスオーバー時のカーソル変更も対象外に。
.custom-md img, #post-cover img {.custom-md img:not(.easyLink-box img):not(.remark-link-card-plus__thumbnail img):not(.remark-link-card-plus__main img), #post-cover img { @apply cursor-zoom-in }}タクソノミーを記事数順に並べる
Categories.astro、Tags.astroのボタンを記事が多い順位並べ替え。
タグの方は記事数が出るようにした。
const categories = await getCategoryList();categories.sort((a, b) => b.count - a.count);<ButtonTag href={getTagUrl(t.name)} label={`View all posts with the ${t.name.trim()} tag`}> {t.name.trim()} {`${t.name.trim()} : ${t.count}`}</ButtonTag>記事カードのサムネを枠内に収める
トップページで表示される記事のサムネイルは、object-coverがセットされている。
サムネイルの解像度が記事ごとにバラバラだったので、デフォルトだと意味不明なサムネイルとなってしまう。
object-containに変更し、サムネ全体が表示されるようにした。
const imageClass = "w-full h-full md:object-contain rounded-xl";const imageClass = "w-full h-full object-cover";cloudflare連携
cloudflare pagesにgithubのリポジトリをリンクして完了。
pagesであればwranglerとかのアダプターやらなんやらの新しい統合は入れなくていい。
tag、categoryページにcanonicalを追加
ahrefsに怒られたので。
tag、categoryページはarchiveページにクエリが付与されるurlになるが、重複コンテンツ扱いとなった。
やり方は単純で、Layout.astroのheadに、urlがarchiveかどうかでlinkタグを入れる入れないの制御を入れただけ。
{ Astro.url.href.endsWith('/archive/') && <link rel="canonical" href={ Astro.url.href }/>}OGPサムネイル設定対応
記事ごとにmetaタグのog:imageをセットするため、以下の実装を持ってきた。
しかし一部問題がある。
_astroフォルダ配下にサムネイルの画像を格納する際、ファイル名にハッシュ文字列が付与されるのだが、ハッシュによってはアクセス不能なURLとなってしまう。
面倒だが都度ファイル名を書き換えて対応している。
さらにデフォルトの設定では、Robots.txtは_astroディレクトリ配下のクロールを禁止しており、OGPのサムネイルが表示されなくなることがある。
なので、画像ファイルだけ許可するよう修正した。
const robotsTxt = `User-agent: *Disallow: /_astro/Allow: /_astro/*.webpAllow: /_astro/*.jpgAllow: /_astro/*.pngAllow: /_astro/*.svgそして以下ページに記事のURLを打ち込んで、Xの既存のポスト上のOGPサムネを反映。
しばらくしたらサムネが表示されるようになった。
fuwariテーマ自体の注意事項
バナーの表示
デフォで無効になっているが、有効にするとスクロールしたときにトップメニューが表示されなくなる(PC、スマホ共々)。
多分バグ。
画像拡大(lightbox)の挙動
開発サーバーでは動かない。 previewサーバーでは動く。