📖

Wasmやモダンフロントエンドを駆使してMarkdownでLT資料を作ってシェアするアプリを作る技術の裏側

2023/04/04に公開

概要

スライド形式の資料が好きな筆者が、Markdownでおしゃれなスライドをつくって、即座にシェアできるようなサービス、Slidevookを開発したので、サービスを支える技術を紹介します。
サーバサイドを作らずに、フロントエンドの技術(Wasm、WebContainer含む)だけで、作り切ることに挑戦したので、幅広く使った技術情報を共有できればと思います。

対象読者

  • 最新(2023年4月現在)のフロントエンドの進化に追従したい方
  • WebContainerの実用例に興味がある方
  • Wasmを利用した動的OGPの生成に興味がある方
  • よくLTで発表をする方
  • MarkdownでLT資料を作成するのが好きな方
  • つくったLT資料を公開するのに、いちいちPDF化してというのが大変という方

つくったもの

Slidevookという、ブラウザ上で、sli.devという、Markdownで素適なSlideが作れるOSSを利用して、Slideをつくって共有できるウェブサービスです。

実際にサービスを使って作ったスライドをシェアしたものの例は、以下のとおりです。
MarkdownでSlideを作って簡単にシェアできる!!Slidevookの使い方


こんな感じのおしゃれなスライドをMarkdown形式でつくって、簡単にシェアすることができます。

サービスの紹介やエモーショナルな部分は、好きだったSlideSha○eが悲しいUXになっていたので、その怒りを糧にSlideの作成・共有サービスを爆誕させた話←こちらの記事に記載しています。

ログインしなくても利用できるでもページがあるのでよかったら、触ってみてください。
(PC版Chromeブラウザ推奨)Demoページはこちら
また、主要な部分(sli.dev、WebContainer APIの利用箇所)は、OSSとして公開しています。slidevook/slidevook-core

バックエンド

このサービスは、技術的な挑戦として、極力フロントエンド側で完結して、作り上げるというテーマで開発を行っています。
バックエンドは、AWS Amplifyを利用しているのですが、バックエンドで、動的OGPを返却するためにLamda@Edge用のコードをちょっと書いたのと、GraphQL用のデータスキーマを設定した以外は、プログラムは一切書いておらず、ほぼフロントエンドの技術で実現しています。
余談ですが、プロトタイプを作っていたときは、WebSocketを使って実現していたので、WebSockerサーバー用のコードをゴリゴリ書いていたのですが、WasmベースのWebContainerにより、バックエンドのコードは一掃することができました。

フロントエンド

前述の通り、サービスの実装は、ほぼすべてフロントエンド向けに実装しています。フロントエンドでくくってしまうと、幅が広すぎるため、提供している機能を軸に、要素となる技術を紹介していきたいと思います。

ビルドツール等のベース部分(Vite,TypeScript,React)

フロントエンドのビルドツールとしては、Viteを採用しています。
Viteの特徴

正直なところ、Viteを利用していなかったら途中で挫折していただろうなというくらい大きな存在でした。非常に高速で、設定もしやすいです。
メインの仕事では、Webpackを使っていることが多いのですが、もしWebpackを使っている方で、Viteを利用したことが無いという方は、極めて優秀なビルドツールなので、是非いますぐ戯れてほしいなって思います。

npm create vite@latest で、ReactTypeScript用のアプリを作っただけで、特に技術的なトピックはないので、気になる方は、ぜひ Viteの公式ガイド を参照しましょう。

また、今述べたとおり、
TypeScript TypeScript

React React

を利用しています。このあたりは、フロントエンドのデファクトスタンダードだと思いますので、この記事では触れません。

UI周り

UI周りでは、以下のライブラリを利用しています。

  • PrimeReact
    • ボタンやツリービューなど、基本的なコンポーネントの利用
    • 特に、ファイルをツリー上に表示する Treeコンポーネント はありがたかったです
    • 型も提供されているので、迷うことなく短時間で不具合なく実装できるのでおすすめです
  • PrimeIcons
    • icon系は、PrimeIconを利用しています。
    • ここは、特に深い理由はなく、PrimeReactを使ったついでといった感じで選びました
    • もし、PrimeReactを使っていなかったら、iconsax を使ってみようかなと考えていましたが、ライセンスがイマイチわからなかったので使っていません。
  • PrimeFlex
    • CSS Utility系は、PrimeFlexを利用しています。
    • ここも、特に深い理由はなく、PrimeReactを使ったついでといった感じで選びました
    • もし、PrimeReactを使っていなかったら、WindiCSS を使っていたと思います。
  • react-mosaic
    • FileTreeやEditorの領域を可変にするために利用しています
    • ※ プロトタイプのときの名残りで、PrimeReactが提供する splitterコンポーネント で十分なので、削除するつもりです。
  • Monaco Editor
    • エディター部分の実装に利用しています
    • VSCodeで利用されている、超有名なエディタですね。
    • 広く使われているライブラリで、型が充実しているので、おすすめです。
      以下の実装で示したように、画像の反映機能(GitHubの画像貼り付け機能のように、Markdownとしての画像参照スニペットをエディタに貼り付けつつ、裏側で画像をアップロードする機能)をささっと作れるなど、カスタマイズ性が高いのもおすすめポイントですね
// クリップボードのペースト時に画像の場合は、アップロードして、画像参照用の文字列をエディタに反映する
const pasteHandler = async (event:ClipboardEvent) => {
  if(event.clipboardData && event.clipboardData.items) {
    const clipboardItem = event.clipboardData.items[0];
    if (clipboardItem.type.startsWith("image/")) {
      event.preventDefault();  // NOTE: prevent monaco editor original paste event
      event.stopPropagation(); // NOTE: prevent monaco editor original paste event
      const uploadedImageFileRelativePath = await uploadImageFile(props.getWebcontainerInstance(), clipboardItem.getAsFile(), workspaceContextValue);
      props.kickReloadFile()
      if( uploadedImageFileRelativePath ) {
        const editor = editorRef.current;
        var line = editor.getPosition();
        if(line) {
          var range = new monaco.Range(line.lineNumber, 1, line.lineNumber, 1);
          var id = { major: 1, minor: 1 };
          var text = `![](${uploadedImageFileRelativePath})`;
          var op = {identifier: id, range: range, text: text, forceMoveMarkers: true};
          editor.executeEdits(null, [op]);
        }
      }
    }
  }
}

// 〜中略〜
// pasteイベントに独自のpasteHandlerを追加
editorElement.addEventListener('paste', pasteHandler, { capture: true });

Wasm関連

今回、バックエンドをほぼ作らずに作りきれたのは、WebAssemblyが、実用に耐えられるところまで進化していたことに他なりません。

WebContainerについて

ブラウザ上で、NodeJSが動く、WebContainerは、非常に強力です。WebContainerがなければ、XTerm.js x Fastify によるWebSocketサーバ 構成で作っていたと思います。※ プロトタイプでは、この構成を採用していました。

WebContainerに関しては、えっ?Browser内でNode.jsアプリが動く?? WebContainerAPIをTypeScriptで動かしてみた で紹介しているので、よかったらそちらもご参照ください。
また、slidevook/slidevook-core にて、WebContainer部分と、WebContainer上で動かしている、Markdownで作ったslideを動かすための、Slidev 用の実装部分を公開しています。

動的OGPの生成

動的にOGPを生成するにあたっては、Vercel製の、satori を利用して動的にOGPを作成しています。
satoriが素晴らしいのは、Wasmとしても提供されており、ブラウザ上で画像生成ができて、なおかつ軽量に動作することにあると思います。
注意点があるとすると、MITやApache 2.0のようなライセンスではなく、Mozilla Public License 2.0ライセンスなので、MITライセンスと比べると制限があるということでしょうか。
今回は、Viteを使って、複数のReactページで構成しているのですが、生成したOGPをシェア時にog:imageとして、提供するにあたって、twitterのボットは、JavaScriptを解釈してくれないので、そこだけは、AWSのLambda@Edgeを利用しました。(他には、ServerSideRenderingという作戦もありますが、今回は、サーバーサイドを極力無くす挑戦をしているので、採用しておりません。)

参考までに、satoriを利用したOGPの作成コードを紹介します。

import satori from 'satori';

// 崩れないように一定サイズ以上の文字列を省略する
const elipsisStringIfSizeOver = ({originalString = '', maxFullSize = 0}) => {
  let halfSizeStringLength = 0;
  let fullSizeStringLength = 0;
  for (let i = 0; i < originalString.length; i++) {
    if(originalString[i].match(/[ -~]/) ) {
      halfSizeStringLength++;
    } else {
      fullSizeStringLength++;
    }

    const totalSize = halfSizeStringLength * 0.5 + fullSizeStringLength;

    if( totalSize > maxFullSize ) {
      return `${originalString.substring(0, i-1)}..`;
    }
  }
  return originalString;
}

export const OgpSize = { width: 1200, height: 630 } as const;
const FrameWidth = 24;
const BottomAreaHeight = 80;
const TitleAreaPadding = 24;

export const generateOgpSVG = async ({fontData = new ArrayBuffer(8), title = '', userName = '', avatarUrl = ''}) => {
  const ogpTitle = title !== '' ? title : 'No-Title';

  const svg = await satori(
    <div
      style={{
        background: '#000',
        display: 'flex',
        height: '100%',
        paddingLeft: FrameWidth,
        paddingTop: FrameWidth,
        width: '100%',
      }}
    >
      <div
        style={{
          backgroundColor: '#FFF',
          display: 'flex',
          flexDirection: 'column',
          justifyContent: 'space-between',
          fontWeight: 600,
          padding: 24,
          width: '100%',
        }}
      >
        <div style={{
          display: 'flex',
          alignItems: 'center',
          height: OgpSize.height - FrameWidth - TitleAreaPadding*2 - BottomAreaHeight,
          padding: 16,
        }}>
          <div style={{ fontSize: 64 }}>
            {elipsisStringIfSizeOver({originalString: ogpTitle, maxFullSize: 68})}
          </div>
        </div>
        <div style={{
          display: 'flex',
          justifyContent: 'space-between',
          height: BottomAreaHeight,
        }}>
          <div style={{
            fontSize: 48,
            display: 'flex',
            alignItems: 'center',
            height: BottomAreaHeight,
          }}>
            { avatarUrl &&
              <img
                src={avatarUrl}
                width={80}
                height={BottomAreaHeight}
                style={{ borderRadius: 50, marginRight: 24 }}
              />
            }
            {elipsisStringIfSizeOver({originalString: userName, maxFullSize: 20})}
          </div>
          <div style={{ display: 'flex'}}>
            <img
              src="slidevook-title.png"
              height={BottomAreaHeight}
              width={353}
            />
          </div>
        </div>
      </div>
    </div>
    ,
    {
      width: OgpSize.width,
      height: OgpSize.height,
      fonts: [
        {
          name: "Noto Serif JP",
          data: fontData,
          style: 'normal',
        },
      ],
    },
  )
  return svg;
}

その他

通信が途切れた時でも、なるべく情報が損なわれないようにしたかったので、ブラウザ上にキャッシュとしてのファイル(画像や文章)を保存しています。
ブラウザ上のファイル保存としては、古くから実装されているLocalStorageが有名かと思いますが、LocalStorageは、最大で5MBまでという制限があり、複数スライド用の複数画像を保存することを考えると、適切な選択肢ではありませんでした。
そのため、IndexedDB を利用しました。can i use IndexedDB で調べても、大半のブラウザが実装しており、こちらも実用に耐えられるところまで進化していることに驚きました。IndexedDBをキャッシュとして利用することで、ネットワーク不調でデータを保存できなかった場合でも、IndexedDBからデータを復旧できるようにしています。

IndexedDBを取り扱うにあたって、Dexie.js を利用しました。読み方がわからなかったので、脳内では、「デュクシ、デュクシ、デュクシ」と呼んでいたのは、内緒です(笑)

例えば、画像の保存部分は、以下のように実装しています。かなりシンプルに記述することができます。

import Dexie, { Table } from "dexie";
import { ImageAssetTableIndex, ImageAssetTableType } from "./ImageAssets";

class WorkspaceDatabase extends Dexie {
  imageAssets!: Table<ImageAssetTableType>;

  constructor(workspaceId:string) {
    super(workspaceId); // NOTE: create database;
    this.version(1).stores({
      imageAssets: ImageAssetTableIndex,
    });
  }
}

export const workspaceDatabase = (workspaceId:string) => new WorkspaceDatabase(workspaceId);
import { workspaceDatabase } from "./WorkspaceDatabase";

export type ImageAssetTableType = {
  path: string,
  content: Uint8Array,
};

export const ImageAssetTableIndex ='path';

export const saveImageToIndexedDB = (workspace:string, acutualFilePath:string, content:Uint8Array) => {
  workspaceDatabase(workspace).imageAssets.put({
    path: acutualFilePath,
    content: content,
  });
}

export const listAllImagesFromIndexedDB = async (workspace:string) => {
  return workspaceDatabase(workspace).imageAssets.toArray();
}

ロゴデザイン

  • ロゴやデザインガイドラインは、AIに多々リテイクしてつくってもらいました
    • https://brandmark.io/
    • $ 65 で購入しました
    • 単純なロゴファイルだけでなく、Tシャツやバッグ、スマフォアプリなどのモックアップのサンプルも作ってくれるので、テンションが爆上がりするので、最初に起爆剤としてやるのがおすすめです。

↓モックアップの例です。これだけでどんぶりめし3杯はいけますね(笑)

おわりに

ここ最近つくっていたサービスの、技術を網羅的に紹介してみました。書き出してみると、アレコレやっていますが、やはり大きいのは、フロントエンドという領域がどんどん進化してきており、特にWebAssemblyによって、フロントエンドの世界が、著しく広がっているなと感じています。
技術的な進歩が早く、乱世!!って感じがしますが、なるべく技術に寄り添いながら、最後は、「これだから乱世は面白い」と笑いながら、次世代にバトンをつなぎていきたいものです。

Discussion