Logicky Blog

Logickyの開発ブログです

SvelteKit, Rust, Cloudflare, renderでブログサイトを作りました

SvelteKitの prerender モードで、このブログサイトを作りました。ブログ記事は md ファイルをビルド時に html ファイル化しています。 Markdown を html に変換するのは、MDsveXを使いました。トップページに Github の各種情報(コミット数、獲得スター数等)を載せたかったので、Rust で Github GraphQL API を使い取得しました。 CDN 等で、Cloudflareも使いました。 サーバはrenderを使いました。 css は、Tailwind CSSを使っていて、Flowbite Svelteも結構使いました。

SvelteKit

SvelteKit はシンプルで最高です。

svelte.config.js

prerender で事前ビルドする際のパスが不明瞭な場合、svelte.config.js で教えてあげる必要がありました。今回はブログ詳細画面について、パスを教える必要がありました。ただ、別のタグ一覧画面などは、パス不明瞭エラーなど出ずに、全部勝手にページを作成してくれました。ブログ詳細画面は、https://xxxx.com/blog/posts/YYYY/MM/DD/xxxxという URL に対して、SvelteKit の routes では、src/routes/blog/posts/[...slug]/+page.svelteという感じで、...slugにまとめてしまったので、不明瞭エラーが出たのかもなーと思いました。ですので、ちょっと工夫すると、下記のような fetchPaths 的なことをして、prerender.entriesに追加するといったことは不要になるかもしれません。

import { mdsvex } from 'mdsvex';
import mdsvexConfig from './mdsvex.config.js';
import preprocess from 'svelte-preprocess';
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/kit/vite';
import matter from 'gray-matter';
import fs from 'fs';
import path from 'path';

const POST_DIR = process.env.POST_DIR || 'src/data/blog/posts';
const POST_URL = '/blog/posts/';

/** @type {import('@sveltejs/kit').Config} */
const config = {
    extensions: ['.svelte', ...mdsvexConfig.extensions],
    preprocess: [
        vitePreprocess(),
        preprocess({
            postcss: true
        }),
        mdsvex(mdsvexConfig)
    ],
    kit: {
        adapter: adapter(),
        alias: {
            components: 'src/lib/components',
            types: 'src/lib/types'
        },
        prerender: {
            entries: ['/blog', ...(await fetchPublishedPostPaths())]
        }
    }
};

function getAllFiles(dirPath, arrayOfFiles) {
    const files = fs.readdirSync(dirPath);
    files.forEach(function (file) {
        if (fs.statSync(dirPath + '/' + file).isDirectory()) {
            arrayOfFiles = getAllFiles(dirPath + '/' + file, arrayOfFiles);
        } else {
            arrayOfFiles.push(path.join(dirPath, '/', file));
        }
    });
    return arrayOfFiles;
}

async function fetchPublishedPostPaths() {
    let routes = [];
    const files = getAllFiles(POST_DIR, []);
    files.forEach((file) => {
        const fileContent = fs.readFileSync(file, 'utf-8');
        const { data } = matter(fileContent);
        if (data?.published !== 'yes') return;
        const pathParts = file.split('/');
        const filename = pathParts.pop().split('.')[0];
        const pathSegments = POST_DIR.split('/');
        const sliceIndex = pathSegments.length;
        const pathToFile = pathParts.slice(sliceIndex).join('/') + '/' + filename;
        routes.push(`${POST_URL}${pathToFile}`);
    });
    return routes;
}

export default config;

環境変数 (env)

ここに書いてあるとおりにしたら出来ました。

.env を下記のように設定します。

CLOUDFLARE_ACCOUNT_ID=your_cloudflare_account_id
CLOUDFLARE_NAMESPACE_ID=cloudflare_namespace_id
CLOUDFLARE_AUTH_TOKEN=your_cloudflare_auth_token
PUBLIC_BLOG_OGP_IMAGE_BASE_URL=url

そして下記のように使えます。dotenv とかは不要です。

import {
    CLOUDFLARE_ACCOUNT_ID,
    CLOUDFLARE_NAMESPACE_ID,
    CLOUDFLARE_AUTH_TOKEN
} from '$env/static/private';

公開可能な環境変数は、PUBLIC_を先頭につけて、下記のように使えます。

import { PUBLIC_BLOG_OGP_IMAGE_BASE_URL } from '$env/static/public';

MDsveX

MDsvex は簡単に SvelteKit に追加できました。ここに書いてあるsvelte-addを使いました。

このリポジトリと、このチュートリアルと、MDsveX のドキュメントを見たら、使い方が大体分かりました。

自分の mdsvex.config.js は今こんな感じでっす。remark/rehype プラグインで、目次をつけて、minify して、code title を表示(コードのパスなどをコードブロックの上に表示)しています。

import { defineMDSveXConfig as defineConfig } from 'mdsvex';
import remarkToc from 'remark-toc';
import remarkSlug from 'remark-slug';
import rehypePresetMinify from 'rehype-preset-minify';
import codeTitle from 'remark-code-titles';

const config = defineConfig({
    extensions: ['.svelte.md', '.md', '.svx'],
    smartypants: {
        dashes: 'oldschool'
    },
    remarkPlugins: [remarkSlug, [remarkToc, { tight: true, heading: '目次' }], codeTitle],
    rehypePlugins: [rehypePresetMinify],
    layout: 'src/lib/components/Layout/BlogLayout.svelte',
    highlight: {}
});

export default config;

front-matter

ちなみに、md ファイルには、タイトル、タグ、公開日時、公開状態を front-matter で設定しました。この投稿だとこんな感じです。node.js では、gray-matterというので、簡単に front-matter を扱うことができました。

---
title: SvelteKit, Rust, Cloudflare, renderでブログサイトを作りました
tags:
  - svelte
  - rust
  - cloudflare
  - render
  - mdsvex
datetime: 2023-07-04 15:30
published: yes
---

OGP

OGP はpnpm ogpを実行すると、自動で現在公開中のすべてのブログ記事の OGP が生成されるようにしました。ですので、リリース前に実行すれば、自動的に OGP が最新状態になります。

OGP 画像生成は、og_image_writerを使いました。背景画像を指定して、タイトルをうまい具合に画像に合体させる的な使い方をしましたが便利でした。

コードはこんな感じでっす。ほとんど CahtGPT 先生が作りました。

use gray_matter::engine::YAML;
use gray_matter::Matter;
use og_image_writer::{
    font_context::FontContext, img::ImageInputFormat, style, writer::OGImageWriter, TextArea,
};
use std::fs;
use std::path::Path;

fn create_ogp_image(title: &str, path: &str) -> anyhow::Result<()> {
    let mut writer = OGImageWriter::from_data(
        style::WindowStyle {
            align_items: style::AlignItems::Center,
            justify_content: style::JustifyContent::Center,
            ..style::WindowStyle::default()
        },
        include_bytes!("assets/images/ogp_back_half.png"),
        ImageInputFormat::Png,
    )?;

    let font_ja = Vec::from(include_bytes!("assets/fonts/NotoSansJP-Regular.ttf") as &[u8]);
    let mut fc = FontContext::new();
    fc.push(font_ja)?;

    let mut textarea = TextArea::new();
    textarea.push_text(title);

    writer.set_textarea(
        textarea,
        style::Style {
            max_width: Some(500),
            max_height: Some(160),
            line_height: 2.0,
            font_size: 40.,
            word_break: style::WordBreak::BreakAll,
            color: style::Rgba([255, 255, 255, 255]),
            text_align: style::TextAlign::Center,
            ..style::Style::default()
        },
        None,
    )?;

    let out_dir = "../../front/static/assets/images/blog/ogp";
    let out_filename = format!("{}.png", path);
    let out_path = format!("{}/{}", out_dir, out_filename);

    if let Some(out_dir) = Path::new(&out_path).parent() {
        fs::create_dir_all(out_dir)?;
    }

    writer.generate(Path::new(&out_path))?;

    Ok(())
}
...

上記を実行すると全公開中ブログ記事に対する下記のような OGP 画像が生成されます。

Flowbite Svelte

Tailwind CSS で作られていて、非常にシンプルなので、扱いやすかったです。次もこれを使おうと思っています。

Rust (Github GraphQL API)

Rust では、Github 関連のデータを取得しています。このコードは cron job 化しようと思って、render だと Rust を簡単にデプロイできるので、Rust にしてみました。typescript で作って、Cloudflare の Worker とかにして、Cloudflare の cron サービスを使うと全部無料でいい感じに作れそうではあったのですが、render でも月 1000 円位っぽかったので、まあいいかと思いました。

Github で取得しているのは、草、コントリビューション数、スター獲得数、フォロワー数、使用言語ランキングという感じです。取得したデータは、json 化して、Cloudflare の KV に入れています。結局まだ cron job 化していませんが、今は、pnpm githubとやると、全部のデータを更新するようにしています。

取得した情報を元に、SvelteKit で下記のような感じで表示しています。今度 Github の README に添付できるように画像化しようかなあとも、ちょっと思っています。

render

render はとにかくデプロイ等が簡単です。独自ドメインの設定なども簡単でした。たしか有料にしないとデプロイが遅いですが、今有料にしていまして、デプロイは特に遅くないかなと思います。

Cloudflare

CDN とか S3 的なやつが高機能で安いやつです。S3 的なやつは R2 といいますが、なんと転送料がかからないらしいです。今回は、とりあえず、CDN と KV を使いました。KV に Rust で取得した Github 関連情報を入れています。それを、SvelteKit の api で取得しています。SvelteKit の api のレスポンスは CDN でキャッシュされるので、結果的に超速くなりました。KV から毎回直接取得していると、1 秒~数秒かかるので速くなかったです。

Cloudflare の CDN を使う場合、基本的にネームサーバを Cloudflare にする必要がありまして、私はドメインは AWS Route53 で管理しているので、AWS のネームサーバを Cloudflare に変更しました。AWS で管理しているドメインのネームサーバを変更するのは、Route53 > 登録済みドメイン > (該当ドメイン)まで進み、右上のアクションから、「ネームサーバの編集」を選択しまっす。これに気づくのに 1 日かかりました。Cloudflare の DNS レコードの管理で、CNAME => xxxx.onrender.com に設定します。設定の詳細は、ここを参考にしました。