🌐

高校生が独りで分散型SNSの専門WEBメディアを作った話

2024/08/20に公開

🌟 この記事を開いていただき、ありがとうございます。

🎉 完成品

Tovuwata FediGazette ウェブサイト
https://tovuwata.github.io/fedigazette
↑ ぜひご覧ください!フォローと RSS 購読もお願いします。

リポジトリ
https://github.com/tovuwata/fedigazette

Lighthouseのスコア

✨ はじめに

最近、X(Twitter)の有料化が懸念される中、Mastodon や Misskey などの分散型 SNS の需要が高まっています。現在、分散型 SNS に関する専門的な WEB メディアとしては、fedimagazine.tokyoGNU social JP Webの 2 つが主要な存在です。しかし、残念ながら、fedimagazine.tokyo がウェブサイトの更新を停止することを発表し、GNU social JP Web もウェブサイトの縮小を発表しています。このように、分散型 SNS に特化した WEB メディアが次々と姿を消している現状には驚きを禁じ得ません。

正直なところ、この分野にどれほどの需要があるのかは分かりません。しかし、今後分散型 SNS に参加しようと考えている人々や、すでに分散型 SNS ライフを楽しんでいる方々に向けて、有益な情報を発信し続けたいと思っています。そのために、私はこのウェブサイトを立ち上げることに決めました。

私自身、時間に余裕があるわけではありませんし、興味を失う可能性もあります。しかし、できる限り更新を続け、分散型 SNS の魅力を伝えていきたいと考えています。このプラットフォームが、情報を求める人々にとっての一助となることを願っています。

また、分散型 SNS の特徴として、ユーザーのプライバシーや自由な表現が重視されています。これらのプラットフォームは、中央集権型のサービスに対抗し、よりオープンで透明性のあるコミュニケーションを提供しています。今後、これらの SNS がどのように進化し、どのようなコミュニティが形成されるのか、非常に楽しみです。

私たちがこのウェブサイトを通じて提供する情報が、分散型 SNS に興味を持つ人々の一助となることを願っています。

そして、今回が初めての Zenn の投稿なので、どうか寛大に見守ってください。

🎯 なぜ記事にしたのか

今回、広告については特に考えておらず、「MisskeyやSNSに投稿すれば自然に広まるだろう」という考えでした。しかし、実際にMisskey、Bluesky、Xに投稿してみると、反応が少なかったです。(みなさん、いいねとフォローお願いします。)
https://misskey.io/notes/9x5ohp3zyty1073a
https://bsky.app/profile/fedigazette.bsky.social/post/3l254dkicko2x
https://x.com/tovuwata/status/1825801688835567926

仮にこれがうまくいかなくても、技術面で他の開発者の役に立てればいいと思い、この記事を書くことにしました。ただし、この記事でアクセス数を増やしたいという思いもあります。

🚀 目標

正直、ここから先は読む人が少ないと思いますが、ここで述べたいと思います。
分散型SNS専門のWEBメディアですが、分散型SNSだけでなく、マイナーなSNS全般についても幅広い情報を発信していくことが目標です。
早速、分散型SNS専門メディアでありながら、それ以外のことも扱いたいという矛盾が生じてしまいました(笑)。
特に、日本製SNSについては注視していきたいと思います。

🌐 とりあえずウェブサイトを作ることにした

サービス名にある「Tovuwata」とは、私が新たに立ち上げた組織の名称です。この組織のサービスの一環として、全てのサービスで新たにウェブサイトを作成する必要がないように、ウェブサイトのテンプレートを作成することにしました。

⚙️ 最終的な使用技術

Bun

JavaScript と TypeScript のための新しいランタイムで、非常に高速なパフォーマンスを提供します。モダンなアプリケーション開発をサポートするために設計されており、パッケージ管理やビルドツールとしても機能します。シンプルな API を持ち、開発者にとって使いやすい環境を提供します。理由は、使ってみたかったからです。

SvelteKit

Svelte フレームワークの公式なアプリケーション開発ツールで、高速なパフォーマンスと優れた開発体験を提供します。静的サイト生成やサーバーサイドレンダリングを簡単に実現できます。

UnoCSS

ユーティリティファーストの CSS フレームワークで、必要なスタイルを動的に生成します。無駄な CSS を排除し、軽量で効率的なスタイルシートを提供します。Tailwind CSS のようなものです。

Unplugin Icons

さまざまなアイコンライブラリを簡単に利用できるプラグインで、アイコンを効率的にインポートし、アプリケーションに美しいビジュアルを追加できます。

Fontsource

フォントを簡単にプロジェクトに組み込むためのライブラリで、フォントをローカルでホストできます。また、スタイルやフォントファミリーを柔軟に選択できます。

mdsvex

Markdown と Svelte を組み合わせたコンポーネントベースのマークアップ言語で、Markdown のシンプルさと Svelte の柔軟性を融合させ、リッチなコンテンツを簡単に作成できます。

Day.js

軽量な日付操作ライブラリで、使いやすい API を提供し、日付のフォーマットや計算を簡単に行えます。

svelte-persisted-store

Svelte のストアをブラウザのローカルストレージに保存するためのライブラリで、アプリケーションの状態を持続的に保存できます。

🛠️ 早速作ってみる

では、あの速くて有名な SolidJS で作ってみましょう 🥳
ここで「上に SvelteKit を使ったと書かれているのに」と思った方もいるでしょう。

そうです。実は、SolidJS でうまく作れず、断念したのです。しかし、その際の躓いた部分も含めて、SolidJS で作った時の話をお伝えします。

ここからはすべての工程を書く必要はないので、要点だけをまとめていきます。

⚙️ SolidJS で作る

このときの使用技術

  • SolidJS
  • UnoCSS
  • Unplugin Icons
  • @solid-primitives/storage

Bun のロックファイルを Git で扱う設定

https://zenn.dev/da1chi/articles/1d39e61b1b28f4
こちらの記事がわかりやすかったので、ぜひご覧ください。

Unplugin Icons

まずはインストール

bash
bun add unplugin-icons

次にアイコンデータのインストール

autoInstall する方法もありますが、今回はまとめて全てインストールしました。

bash
bun add -D @iconify/json

Unplugin Icons の設定

app.config.tsを開きます。

app.config.ts
import Icons from 'unplugin-icons/vite';

まずは上記のように Icons をインポートします。

次に、以下のように設定を追加します。

app.config.ts
export default defineConfig({
  vite: {
    plugins: [
      Icons({
        compiler: 'solid',
        autoInstall: true,
      }),
    ],
  },
});

この設定を vite の中に入れることで、vite の設定に反映されます(?)

次に、tsconfig.jsonを開きます。

tsconfig.json
{
  "compilerOptions": {
    "types": ["vinxi/types/client", "unplugin-icons/types/solid"]
  }
}

compilerOptionstypes"unplugin-icons/types/solid"を追加します。

UnoCSS で@apply や@screen、theme()を使いたい

初めての方はここで意外と苦戦しがちです。

まず、以下のコマンドで必要なパッケージをインストールします。

bash
bun add -D @unocss/transformer-directives

https://unocss.dev/transformers/directives
こちらのドキュメントを参考に、設定を行います。

app.config.tsを以下のように設定します。

app.config.ts
import { transformerDirectives } from 'unocss';

export default defineConfig({
  vite: {
    plugins: [
      UnoCSS({
        transformers: [transformerDirectives()],
      }),
    ],
  },
});

フォント

使用するフォントは以下の通りです。

  • Roboto
  • Noto Sans JP
  • Noto Emoji

共有ボタン

共有ボタンの実装は少し面倒ですが、AddToAnyを使えば簡単に作成できます。また、Unplugin Icons の Simple Icons を使って、自分でおしゃれな共有ボタンを作りたい方もいると思います。以下に共有ボタン用の URL をまとめましたが、今後変更される可能性があるため、動作しない場合は最新の URL を調べてください。

共有用リンク一覧

Mastodon

  • 使い方: Mastodon で投稿を作成するためのリンクです。
  • : https://mastoshare.net/share?text=こんにちは、これはテストです!

Misskey

  • 使い方: Misskey に投稿をシェアするためのリンクです。
  • : https://misskey-hub.net/share?text=このリンクを試してみてください!

Bluesky

  • 使い方: Bluesky に投稿するためのリンクです。
  • : https://bsky.app/intent/compose?text=新しいプラットフォームを試しています!

X (Twitter)

  • 使い方: Twitter に投稿するためのリンクです。
  • : https://x.com/intent/post?text=みんな、こんにちは!

Threads

  • 使い方: Threads に投稿をシェアするためのリンクです。
  • : https://threads.net/intent/post?text=このアプリが好きです!

Facebook

  • 使い方: Facebook でシェアするためのリンクです。
  • : https://www.facebook.com/sharer/sharer.php?u=https://example.com

Linkedin

  • 使い方: LinkedIn で記事をシェアするためのリンクです。
  • : https://www.linkedin.com/shareArticle?mini=true&url=https://example.com&title=新しい記事&summary=ぜひ読んでください!

Reddit

  • 使い方: Reddit に投稿するためのリンクです。
  • : http://www.reddit.com/submit?title=面白い記事&url=https://example.com

LINE

  • 使い方: LINE でメッセージをシェアするためのリンクです。
  • : https://social-plugins.line.me/lineit/share?text=このリンクを見て!

Whatsapp

  • 使い方: WhatsApp でメッセージを送るためのリンクです。
  • : https://api.whatsapp.com/send?text=友達にシェアしたいリンクです!

Telegram

  • 使い方: Telegram でメッセージをシェアするためのリンクです。
  • : https://t.me/share/url?url=https://example.com&text=ぜひチェックしてください!

Viber

  • 使い方: Viber でメッセージを送るためのリンクです。
  • : viber://forward?text=この情報を共有したいです!

はてなブックマーク

  • 使い方: はてなブックマークにブックマークを追加するリンクです。
  • : https://b.hatena.ne.jp/my/add.confirm?url=https://example.com

Pocket

  • 使い方: Pocket に記事を保存するためのリンクです。
  • : https://widgets.getpocket.com/v1/popup?url=https://example.com&title=興味深い記事

コピーボタン

以下のコードでテキストをクリップボードにコピーできます。ただし、localhost と https 環境でのみ動作します。

navigator.clipboard.writeText('テキスト');

OS の共有ボタン

以下のコードで OS の共有機能を利用できます。こちらも localhost と https 環境でのみ動作します。

navigator.share({
  title: 'タイトル',
  url: 'URL',
  text: 'テキスト',
});

ダークモード対応

ここが一番面倒だったかもしれません。ダークモードの切り替え状況を保存するために、@solid-primitives/storageを使用しました。src/contexts/DarkModeContext.tsxに以下のように実装しました。

src/contexts/DarkModeContext.tsx
import {
  createContext,
  createEffect,
  createSignal,
  JSX,
  useContext,
} from 'solid-js';
import { makePersisted } from '@solid-primitives/storage';

const DarkModeContext = createContext<{
  isDarkMode: () => boolean;
  toggleDarkMode: () => void;
}>();

export const DarkModeProvider = (props: { children: JSX.Element }) => {
  const [isDarkMode, setIsDarkMode] = makePersisted(createSignal(false), {
    name: 'darkMode',
  });

  createEffect(() => {
    const htmlElement = document.documentElement;
    if (isDarkMode()) {
      htmlElement.classList.add('dark');
    } else {
      htmlElement.classList.remove('dark');
    }
  });

  const toggleDarkMode = () => {
    setIsDarkMode(!isDarkMode());
  };

  return (
    <DarkModeContext.Provider value={{ isDarkMode, toggleDarkMode }}>
      {props.children}
    </DarkModeContext.Provider>
  );
};

export const useDarkMode = () => {
  const context = useContext(DarkModeContext);
  if (!context) {
    throw new Error('useDarkMode must be used within a DarkModeProvider');
  }
  return context;
};

このコンテキストを使用するために、App.tsxMetaProviderの下に以下のようにタグを挿入します。

App.tsx
import { DarkModeProvider } from './contexts/DarkModeContext';
// ...

const App = () => {
  return (
    <Router
      root={(props) => (
        <MetaProvider>
          <DarkModeProvider>// ...</DarkModeProvider>
        </MetaProvider>
      )}
    >
      <FileRoutes />
    </Router>
  );
};

export default App;

これでダークモードの実装は完了です。動作するので問題ないでしょう。ダークモード用のスタイルを UnoCSS で設定するには、dark:text-whiteのように先頭にdark:を付けて適用します。

ブログ記事の表示画面を作成中の問題

ブログ記事を表示するために Markdown 対応を考え、SolidJS のブログを参考にしました。こちらではsolid-mdxを使用していました。使い方を調べて試しましたが、うまくいかず諦めました。そこで、少し触ったことのある Svelte に切り替えることにしました。

⚙️ Svelte での開発

使用技術

最終的な使用技術を参照してください。

気を取り直して、Svelte での開発を進めます。

SolidJS からの書き換え

コードの書き換えは面倒でしたが、ChatGPT に任せました。ディレクトリ構造は自分で整えました。

UnoCSS の設定

https://unocss.dev/integrations/svelte-scoped
Svelte での UnoCSS の設定は少し手間がかかります。

まずはインストールします。

bash
bun add -D unocss @unocss/svelte-scoped

次に、svelte.config.jsを開きます。

svelte.config.js
import UnoCSS from '@unocss/svelte-scoped/preprocess';
// ...

const config = {
  preprocess: [vitePreprocess(), UnoCSS()],
};

このように設定します。

次に、uno.config.tsを作成します。

uno.config.ts
import { defineConfig, presetUno } from 'unocss';

export default defineConfig({
  presets: [presetUno()],
});

ここに UnoCSS のオプションを指定します。

UnoCSS で@apply や@screen、theme()を使う

vite.config.tsを開き、以下のように設定します。

vite.config.ts
import transformerDirectives from '@unocss/transformer-directives';

export default defineConfig({
  plugins: [
    UnoCSS({
      cssFileTransformers: [transformerDirectives()],
    }),
    sveltekit(),
  ],
});

SSG のためのプリレンダー設定

このサイトは GitHub Pages にホストしているため、SSG(静的サイト生成)を行っています。SSG の際に/blog/[slug]のようにパラメータslugを使ったルートを使用する場合、プリレンダーの設定が必要です。

svelte.config.jsに以下のように設定します。

svelte.config.js
// ...

const config = {
  // ...

  prerender: {
    entries: ['/blog/1'],
  },
};

export default config;

entriesの中にプリレンダーするパスを指定します。後述のように svx で記事を自動的に追加したい場合は、次のようにします。

svelte.config.js
// ...

import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

const newsDir = path.resolve(__dirname, './src/routes/news/posts');
const newsSvxFiles = fs
  .readdirSync(newsDir)
  .filter((file) => file.endsWith('.svx'));

const newsEntries = newsSvxFiles.map(
  (file) => `/news/view/${file.replace('.svx', '')}`
);

const config = {
  // ...

  prerender: {
    entries: [
      // ...
    ].concat(newsEntries),
  },
};

export default config;

RSS フィードを追加する

https://github.com/sveltejs/svelte/blob/main/sites/svelte.dev/src/routes/blog/rss.xml/+server.js
Svelte.dev のブログ RSS フィードを参考にして RSS フィードを作成します。

例えば、/blog/rss.xmlに RSS フィードを追加するには、src/routes/blog/rss.xml/+server.jsに以下のコードを記述します。

src/routes/blog/rss.xml/+server.js
import dayjs from 'dayjs';

interface PostMetadata {
  title: string;
  date: string;
  description?: string;
}

interface Post {
  metadata: PostMetadata;
  slug: string;
}

export async function GET() {
  const modules = import.meta.glob('../posts/*.svx');
  const posts: Post[] = [];

  for (const path in modules) {
    const module = (await modules[path]()) as { metadata: PostMetadata };
    const slug = path.split('/').pop()?.replace('.svx', '') || '';
    posts.push({ metadata: module.metadata, slug });
  }

  const latestPosts = posts
    .sort((a, b) => dayjs(b.metadata.date).valueOf() - dayjs(a.metadata.date).valueOf())
    .slice(0, 10);

  const feed = `
    <rss version="2.0">
      <channel>
        <title>ニュース</title>
        <link>https://example.com/news</link>
        <description>ニュースの最新10件の記事一覧です。</description>
        ${latestPosts
          .map(
            (post) => `
          <item>
            <title>${post.metadata.title}</title>
            <link>https://example.com/news/view/${post.slug}</link>
            <pubDate>${dayjs(post.metadata.date).toString()}</pubDate>
            <description>${post.metadata.description || ''}</description>
          </item>
        `
          )
          .join('')}
      </channel>
    </rss>
  `;

  return new Response(feed, {
    headers: {
      'Content-Type': 'application/xml',
    },
  });
}

export const prerender = true;

Markdown の記事表示 (SolidJS での失敗)

結論から言うと、mdsvexを使用することで以下のように実装できました。

<script lang="ts">
  import { onMount, SvelteComponent } from 'svelte';
  import { page } from '$app/stores';

  let content: SvelteComponent | null = null;
  let metadata: {
    title: string;
    date: string;
    description?: string;
    author?: string;
  } | null = null;
  let err: string | null = null;

  onMount(async () => {
    const slug = $page.params.slug;
    try {
      const post = await import(`../../posts/${slug}.svx`);
      content = post.default;
      metadata = post.metadata;
    } catch (error) {
      console.log(error);
      err = '投稿がありません。';
    }
  });
</script>

<section class="section md">
  {#if content && metadata}
    <article>
      <h1>{metadata.title ?? ''}</h1>
      <p>投稿日時: {metadata.date ?? ''}</p>
      <p>ライター: {metadata.author ?? ''}</p>
      <div class="divider"></div>
      <svelte:component this={content} />
    </article>
  {:else if err}
    <p>{err}</p>
  {:else}
    <p>読み込み中...</p>
  {/if}
</section>

詳しくはコードを読んでください。

デプロイ

package.jsonに以下のように記述します。

package.json
{
  // ...

  "scripts": {
    // ...

    "deploy": "bun run build && echo . > build/.nojekyll && npx gh-pages -d build -t true --message \"🚀 デプロイ\""

    // ...
  }

  // ...
}

テンプレートの変更を取り込み

GitHub の「Use Template」でリポジトリを作成すると、テンプレートの変更は反映されなくなります。引き続きテンプレートの変更を取り込みたい場合、package.jsonに以下のように記述します。

package.json
{
  // ...

  "scripts": {
    // ...

    "add-template": "git remote add template {{リポジトリのURL}}",
    "update-template": "git fetch template && git merge template/main --allow-unrelated-histories"

    // ...
  }

  // ...
}

最初の一回だけ以下を実行します。

bash
bun run add-template

次からは以下で変更をすぐに取り込めます。

bash
bun run update-template

🌈 最後に

ここまで、読んでいただきありがとうございました。
ぜひ、記事への改善点やアドバイスをお聞かせください。

どうぞよろしくお願いいたします。

Discussion