Blog

絵文字代わりにSVGアイコンを使えるようにする

Cover Image for 絵文字代わりにSVGアイコンを使えるようにする

前々から、ブログでもLINEみたいに自分の好きな絵柄を文中に入れて感情表現できたらいいなぁって思ってたんだけど、 今回はGPTと相談しながらSVGアイコンを表示できる機能を追加したよ

現状ではいろいろ問題ありなんだけど、とりあえず記録として残しておくよ

ちなみに今回使ってるSVGは全部こちらのものを使わせていただいたよ。

https://www.svgrepo.com/collection/doodle-library-hand-drawn-vectors/

絵文字、ピクセル画像、SVG画像の比較

絵文字的な感じで文章中で何個も使う場合...

絵文字

  • 画像ではなく、文字だよ
  • OSに保存されているため、超軽量だよ
  • カスタムのフォントを作ることは可能だけど、その場合はブラウザ側で画像をダウンロードすることになって、パフォーマンス上のメリットがなくなるよ

ピクセル画像

  • PNGなどの画像をサーバー上に配置して、imgタグなどで参照して使うよ
  • ちっちゃい画像でも、サイズが大きいことが多いよ
  • 一個使うたびにダウンロードリクエストが走るから、時間がかかるよ
  • オリジナルの絵文字を作成することができるよ

SVG画像

  • SVG画像をサーバー上に配置して参照するか、HTMLに埋め込んで使うよ
  • 画像を参照する場合はダウンロードリクエストが走るよ
  • HTMLに埋め込む場合はHTMLと一緒にダウンロードされるよ
  • ダウンロードする場合でもピクセル画像よりは軽いよ
  • オリジナルの絵文字を作成することができるよ

オリジナルの絵文字を作成したかったのと、うちのブログはSSGだから、HTMLに埋め込んでおけばアクセス時にリクエスト不要っていうわけでSVGにしたよ

運用フロー

  • SVGアイコンを追加したら全部のアイコンのIDとコンテンツを保存するJSONを更新するAPIを叩く
  • コンテンツ作成時はSVGアイコンを入れたいところにトークンを入れておく
  • サイトのビルド時にJSONを参照しながら各コンテンツのトークンをSVGに置換

SSGとはいえビルド時にアイコン一個ごとにファイルを読み込んでたら遅いだろうと思ってJSONに保存してるよ。

SVGアイコンを保存する

SVGアイコンをどうサイト上に持たせるか迷ったんだけど、とりあえず今はこんな風にしてるよ

public/icons
├── original
│   ├── butterfly.svg
│   ├── cat.svg
│   ├── catsmile.svg
│   ├── unya.svg
│   └── waa.svg
└── icons.json
  • originalディレクトリの中に未加工のSVG画像ファイルを置いているよ
  • icons.jsonの中にはこんな感じで全部のSVGの名前とデータが保存されているよ
{
  "icons": [
    {
      "name": "bunny",
      "svg": "<?xml version=\"1.0\" encoding=\"utf-8\"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->\n<svg   viewBox=\"0 0 400 400\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\r\n<path d=\"M138.546 199.585C129.21 185.366 124.071 168.661 115.695 153.711C108.408 140.713 81.6988 55.0985 118.14 60.3242C152.857 65.3048 167.3 124.197 173.643 151.253C177.301 166.857 177.793 188.937 178.538 188.937C179.302 188.937 187.99 158.732 189.151 156.169C208.696 113.002 233.224 84 264.239 55.0001C295.254 26.0002 295.106 42.0806 297.699 43.1209C303.278 45.3622 311 89.9999 280.5 139C250 188 239.59 199.585 233.224 210.233C231.519 213.085 266 232 266 270C266 308.999 232.304 354 189.152 354C146 354 88.9998 310 115.695 223\" stroke=\"#000000\" stroke-opacity=\"0.9\" stroke-width=\"16\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\r\n<path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M189.445 293.566C167 274 200.454 277.316 201.396 280.512C202.237 283.361 191.731 291.141 189.445 293.566Z\" stroke=\"#000000\" stroke-opacity=\"0.9\" stroke-width=\"16\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\r\n<path d=\"M173 316.91C186.916 322.974 194.976 322.245 205.94 311\" stroke=\"#000000\" stroke-opacity=\"0.9\" stroke-width=\"16\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\r\n<path d=\"M189.65 299.518C189.794 303.558 189.129 307.336 188.67 311.144\" stroke=\"#000000\" stroke-opacity=\"0.9\" stroke-width=\"16\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\r\n<path d=\"M177.504 252.514C177.504 249.929 177.504 247.348 177.504 244.763\" stroke=\"#000000\" stroke-opacity=\"0.9\" stroke-width=\"16\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\r\n<path d=\"M202.693 250.826C202.693 248.163 202.693 245.495 202.693 242.826\" stroke=\"#000000\" stroke-opacity=\"0.9\" stroke-width=\"16\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\r\n</svg>"
    },
    {
      "name": "butterfly",
      "svg": "<?xml version=\"1.0\" encoding=\"utf-8\"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->\n<svg   viewBox=\"0 0 400 400\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\r\n<path d=\"M179.222 296.039C171.929 321.836 117.118 349.829 100.868 317.327C83.9601 283.513 128.707 258.291 155.272 253.461\" stroke=\"#000000\" stroke-opacity=\"0.9\" stroke-width=\"16\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\r\n<path d=\"M154.089 255.235C140.063 256.692 126.178 245.826 114.468 239.268C74.9513 217.139 49.2305 174.06 54.7419 127.208C60.5666 77.6967 120.975 104.935 142.262 123.068C160.693 138.769 174.617 160.101 184.839 181.908C193.152 199.64 200.112 214.666 205.536 233.651\" stroke=\"#000000\" stroke-opacity=\"0.9\" stroke-width=\"16\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\r\n<path d=\"M211.746 221.528C216.22 203.929 220.6 185.643 227.417 168.602C234.927 149.827 243.612 130.546 256.097 114.494C267.281 100.115 309.676 53.8821 331.79 73.9865C338.609 80.1856 341.437 92.7089 343.321 101.189C355.43 155.676 303.486 234.412 246.044 240.156\" stroke=\"#000000\" stroke-opacity=\"0.9\" stroke-width=\"16\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\r\n<path d=\"M249.001 238.972C282.8 227.116 321.478 272.606 305.179 305.204C286.258 343.046 248.409 334.476 215.589 283.62\" stroke=\"#000000\" stroke-opacity=\"0.9\" stroke-width=\"16\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\r\n<path d=\"M205.537 171.56C213.34 215.845 203.3 268.936 199.919 312.892\" stroke=\"#000000\" stroke-opacity=\"0.9\" stroke-width=\"16\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\r\n</svg>"
    },
    ...

icons.jsonを生成するAPI

ほぼGPTが作ってくれたよ。 GETを送るとicons/originalディレクトリ内の全部のSVGの名前とコンテンツをicons.jsonに保存してくれるよ。

import fs from "fs";
import path from "path";

export async function GET() {
  try {
    // Define the directory where original SVG files are stored
    const iconsDir = path.join(process.cwd(), "public/icons/original");

    // Define the output JSON file
    const outputJson = path.join(process.cwd(), "public/icons/icons.json");

    const files = fs.readdirSync(iconsDir).filter(file => file.endsWith(".svg"));

    const iconsData = files.map(file => {
      const filePath = path.join(iconsDir, file);
      let content = fs.readFileSync(filePath, "utf-8");
      // SVGタグのwidthとheightを削除.
      content = content.replace(/width="[^"]*"/, "").replace(/height="[^"]*"/, "");
      return {
        name: path.basename(file, ".svg"), // Remove .svg extension
        svg: content
      };
    });

    // Write the JSON file
    fs.writeFileSync(outputJson, JSON.stringify({ icons: iconsData }, null, 2));

    return new Response(
      JSON.stringify({ success: true, message: "Icons JSON updated!" }),
      { status: 200, headers: { "Content-Type": "application/json" } }
    );
  } catch (error) {
    console.error("❌ Error generating icons.json:", error);
    return new Response(
      JSON.stringify({ success: false, message: "Error updating icons.json" }),
      { status: 500, headers: { "Content-Type": "application/json" } }
    );
  }
}

SVGの方でwidthとheightが指定されてるとラッパーのサイズを変えてもなぜか突き抜けちゃうから削除してるよ。

トークンの変換処理

マークダウン側では、{icon:[symbol ID]}でアイコンを指定してるよ。[synmol ID]にはcatとかpenが入るよ。

マークダウンをHTMLに変換した後に、正規表現で{icon:[symbol ID]}の文字列を見つけて、それをicons.jsonのHTMLに変換するよ。

iimport { remark } from "remark";
import html from "remark-html";
import fs from "fs";
import path from "path";
import { join } from "path";

export default async function replaceIconToHtml(dom: string) {
  // アイコンのトークンをHTMLに変換する.
  let iconTokens = dom.match(/\{icon:[a-zA-Z0-9]*\}/g);
  if (iconTokens) {
    iconTokens.forEach((iconToken) => {
      const iconId = iconToken.replace("{icon:", "").replace("}", "");
      const svg = getIconHtml(iconId);
      dom = dom.replace(iconToken, svg);
    });
  }
  return dom;
}

// SVGのHTMLを取得する.
function getIconHtml(iconId: string) {
  const json = JSON.parse(fs.readFileSync(join(process.cwd(), "public/icons/icons.json"), "utf-8"));
  const svg = json.icons.find((icon) => icon.name === iconId);
  if (!svg) {
    return "";
  }
  return `<span style="display: inline-block; vertical-align: middle; height: 2em; width: 2em;">
    ${svg.svg}
  </span>`;
}

なぜかこの部分ではTailwind効かなくて、とりあえずstyle属性使ってるよ。SVGはデフォルトではブロックだからインラインにして、アイコンの大きさは余白の分小さく見えるから、親要素の2倍にしてるよ。

これを、もともとのマークダウンからHTMLへの変換処理の後に呼び出してるよ。

import { remark } from "remark";
import html from "remark-html";
import replaceIconToken from "./iconTokenReplace";

export default async function markdownToHtml(markdown: string) {
  let dom = (await remark().use(html).process(markdown)).toString();

  // アイコンのトークンをHTMLに変換する.
  dom = await replaceIconToken(dom);

  return dom;
}

でも一個問題があって、これだとトークンをコードブロックに入れても、引用しても、問答無用でアイコンに変換されちゃう

ここらへんのエスケープ処理どうしよう?考え中

追記

トークンのエスケープ処理

HTMLをオブジェクト変換とかしないといけないかなって思ってたけど、トークン置換前にコードブロックの中身だけ配列に保存して、トークン置換後に元に戻したらいけたよ

ほんとうはこんなトークンなの{icon:cat}

見出しアイコン機能の実装

Markdownのメタデータでこんなふうに見出しのアイコンを設定すると、

---
title: 絵文字代わりにSVGアイコンを使えるようにする
...
icons:
  h2: ghostskater
  h3: pen
---

ビルド時に見出しの先頭にアイコンを挿入してくれるようになったよ