高校生が独りで分散型SNSの専門WEBメディアを作った話
🌟 この記事を開いていただき、ありがとうございます。
🎉 完成品
Tovuwata FediGazette ウェブサイト
↑ ぜひご覧ください!フォローと RSS 購読もお願いします。リポジトリ
Lighthouseのスコア
✨ はじめに
最近、X(Twitter)の有料化が懸念される中、Mastodon や Misskey などの分散型 SNS の需要が高まっています。現在、分散型 SNS に関する専門的な WEB メディアとしては、fedimagazine.tokyoとGNU social JP Webの 2 つが主要な存在です。しかし、残念ながら、fedimagazine.tokyo がウェブサイトの更新を停止することを発表し、GNU social JP Web もウェブサイトの縮小を発表しています。このように、分散型 SNS に特化した WEB メディアが次々と姿を消している現状には驚きを禁じ得ません。
正直なところ、この分野にどれほどの需要があるのかは分かりません。しかし、今後分散型 SNS に参加しようと考えている人々や、すでに分散型 SNS ライフを楽しんでいる方々に向けて、有益な情報を発信し続けたいと思っています。そのために、私はこのウェブサイトを立ち上げることに決めました。
私自身、時間に余裕があるわけではありませんし、興味を失う可能性もあります。しかし、できる限り更新を続け、分散型 SNS の魅力を伝えていきたいと考えています。このプラットフォームが、情報を求める人々にとっての一助となることを願っています。
また、分散型 SNS の特徴として、ユーザーのプライバシーや自由な表現が重視されています。これらのプラットフォームは、中央集権型のサービスに対抗し、よりオープンで透明性のあるコミュニケーションを提供しています。今後、これらの SNS がどのように進化し、どのようなコミュニティが形成されるのか、非常に楽しみです。
私たちがこのウェブサイトを通じて提供する情報が、分散型 SNS に興味を持つ人々の一助となることを願っています。
そして、今回が初めての Zenn の投稿なので、どうか寛大に見守ってください。
🎯 なぜ記事にしたのか
今回、広告については特に考えておらず、「MisskeyやSNSに投稿すれば自然に広まるだろう」という考えでした。しかし、実際にMisskey、Bluesky、Xに投稿してみると、反応が少なかったです。(みなさん、いいねとフォローお願いします。)
仮にこれがうまくいかなくても、技術面で他の開発者の役に立てればいいと思い、この記事を書くことにしました。ただし、この記事でアクセス数を増やしたいという思いもあります。
🚀 目標
正直、ここから先は読む人が少ないと思いますが、ここで述べたいと思います。
分散型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 で扱う設定
こちらの記事がわかりやすかったので、ぜひご覧ください。
Unplugin Icons
まずはインストール
bun add unplugin-icons
次にアイコンデータのインストール
autoInstall する方法もありますが、今回はまとめて全てインストールしました。
bun add -D @iconify/json
Unplugin Icons の設定
app.config.ts
を開きます。
import Icons from 'unplugin-icons/vite';
まずは上記のように Icons をインポートします。
次に、以下のように設定を追加します。
export default defineConfig({
vite: {
plugins: [
Icons({
compiler: 'solid',
autoInstall: true,
}),
],
},
});
この設定を vite の中に入れることで、vite の設定に反映されます(?)
次に、tsconfig.json
を開きます。
{
"compilerOptions": {
"types": ["vinxi/types/client", "unplugin-icons/types/solid"]
}
}
compilerOptions
のtypes
に"unplugin-icons/types/solid"
を追加します。
UnoCSS で@apply や@screen、theme()を使いたい
初めての方はここで意外と苦戦しがちです。
まず、以下のコマンドで必要なパッケージをインストールします。
bun add -D @unocss/transformer-directives
こちらのドキュメントを参考に、設定を行います。
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 でシェアするためのリンクです。
-
例:
https://www.facebook.com/sharer/sharer.php?u=https://example.com
- 使い方: LinkedIn で記事をシェアするためのリンクです。
-
例:
https://www.linkedin.com/shareArticle?mini=true&url=https://example.com&title=新しい記事&summary=ぜひ読んでください!
- 使い方: Reddit に投稿するためのリンクです。
-
例:
http://www.reddit.com/submit?title=面白い記事&url=https://example.com
LINE
- 使い方: LINE でメッセージをシェアするためのリンクです。
-
例:
https://social-plugins.line.me/lineit/share?text=このリンクを見て!
- 使い方: 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 に記事を保存するためのリンクです。
-
例:
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
に以下のように実装しました。
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.tsx
のMetaProvider
の下に以下のようにタグを挿入します。
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 の設定
Svelte での UnoCSS の設定は少し手間がかかります。
まずはインストールします。
bun add -D unocss @unocss/svelte-scoped
次に、svelte.config.js
を開きます。
import UnoCSS from '@unocss/svelte-scoped/preprocess';
// ...
const config = {
preprocess: [vitePreprocess(), UnoCSS()],
};
このように設定します。
次に、uno.config.ts
を作成します。
import { defineConfig, presetUno } from 'unocss';
export default defineConfig({
presets: [presetUno()],
});
ここに UnoCSS のオプションを指定します。
UnoCSS で@apply や@screen、theme()を使う
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
に以下のように設定します。
// ...
const config = {
// ...
prerender: {
entries: ['/blog/1'],
},
};
export default config;
entries
の中にプリレンダーするパスを指定します。後述のように svx で記事を自動的に追加したい場合は、次のようにします。
// ...
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 フィードを追加する
Svelte.dev のブログ RSS フィードを参考にして RSS フィードを作成します。
例えば、/blog/rss.xml
に RSS フィードを追加するには、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
に以下のように記述します。
{
// ...
"scripts": {
// ...
"deploy": "bun run build && echo . > build/.nojekyll && npx gh-pages -d build -t true --message \"🚀 デプロイ\""
// ...
}
// ...
}
テンプレートの変更を取り込み
GitHub の「Use Template」でリポジトリを作成すると、テンプレートの変更は反映されなくなります。引き続きテンプレートの変更を取り込みたい場合、package.json
に以下のように記述します。
{
// ...
"scripts": {
// ...
"add-template": "git remote add template {{リポジトリのURL}}",
"update-template": "git fetch template && git merge template/main --allow-unrelated-histories"
// ...
}
// ...
}
最初の一回だけ以下を実行します。
bun run add-template
次からは以下で変更をすぐに取り込めます。
bun run update-template
🌈 最後に
ここまで、読んでいただきありがとうございました。
ぜひ、記事への改善点やアドバイスをお聞かせください。
どうぞよろしくお願いいたします。
Discussion