Deno の Web フレームワーク Fresh チュートリアル
Fresh は Deno 製の Web フレームワークです。事前のビルドを必要せず、エッジでレンダリングを提供するという特徴があります。また Islands Architecture を採用しており、デフォルトではクライアントに JavaScript が配信されることがありません。
この記事では Fresh を使用して記事投稿サービスのチュートリアルを紹介します。
完成したサイトは以下のようになります。
ソースコードは以下のレポジトリから確認できます。
インストール
Fresh を始めるには Deno の v1.22.3 バージョン以降が必要です。Deno をまだインストールしたことがないのならば、installation を参考に Deno をインストールしましょう。
# Shell (Mac, Linux)
$ curl -fsSL https://deno.land/install.sh | sh
# PowerShell (Windows):
$ iwr https://deno.land/install.ps1 -useb | iex
すでに Deno をインストール済みなら下記コマンドで Deno を最新バージョンに更新します。
$ deno upgrade
バージョンを確認しましょう。
$ deno --version
deno 1.23.0 (release, x86_64-apple-darwin)
v8 10.4.132.5
typescript 4.7.2
Deno のバージョンが v1.22.3 以降であることを確認したら、下記コマンドで Fresh のプロジェクトを作成します。
$ deno run -A --no-check https://raw.githubusercontent.com/lucacasonato/fresh/main/init.ts my-app
プロジェクトの作成が完了したら、次のコマンドで開発サーバーを立ち上げてみましょう。
$ cd my-app
$ deno task start
http://localhost:8080 にアクセスしてみましょう。以下の画面が表示されているはずです!
ディレクトリ構成
コードを書き始める前にまずは生成されたディレクトリ構成を確認してみましょう。
dev.ts
開発時のエントリーポイントです。deno task start
コマンドでは dev.ts
ファイルを実行します。
main.ts
本番時のエントリーポイントです。このファイルは Deno Deploy でリンクします。
fresh.gen.ts
ルーティングと islands の情報を含むマニフェストファイルです。このファイルは routes/
ディレクトリと islands/
ディレクトリをもとに開発中は自動で生成されます。
import_map.json
Import maps は Deno がサポートしている機能です。この機能は依存関係の管理を容易に行うために使用されます。
Deno は Node.js における npm のようなパッケージマネージャーを持っていないので、以下のように URL 指定でインポートして実行時にパッケージをインストールします。
import chunk from "https://deno.land/x/lodash@4.17.15-es/chunk.js";
console.log(chunk(["a", "b", "c", "d"], 2));
この方法は書き捨てのスクリプトならば問題ないのですが、大きなプロジェクトになった際に問題が発生します。例えば lodash
が複数のファイルにおいて import されている場合、バージョンアップのたびにすべてのファイルを修正する必要があります。この問題を解決するため、慣例的に deps.ts
という名前のファイルを作成して依存関係を 1 つにまとめるという方法が使われていました。
Fresh では前述のとおり Import maps を使用して依存関係を管理します。Import maps は JSON 形式で記述しキー名が import に使用する名前、値がその import したときに解決されるパスを指定しています。
{
"imports": {
"$fresh/": "https://raw.githubusercontent.com/lucacasonato/fresh/main/",
"lodash/": "https://deno.land/x/lodash@4.17.15-es/"
}
}
lodash を使用するファイルでは from "lodash/..."
の形式で import できます。
import chunk from "lodash/chunk.js";
console.log(chunk(["a", "b", "c", "d"], 2));
Import maps を使用する際には Deno の実行時に --import-map=<FILE>
オプションを指定します。
$ deno run test.ts --import-map=import_map.json
deno.json
deno.json は Deno の設定ファイルです。TypeScript コンパイラ、フォーマッタ、リンタをカスタマイズするために使われます。deno.json
ファイルは必須の機能ではなく、あくまでオプションの立ち位置です。
自動生成された deno.json
ファイルでは以下の 2 つの設定が記載されています。
-
tasks
:npm scripts と同じように、プロジェクトで使用するコマンドをまとめることができます。ここの記述したコマンドはdeno task
コマンドで使用できます。 -
importMap
:Import map の配置場所をコマンドオプションで指定する代わりに記述します。
{
"tasks": {
"start": "deno run -A --watch=static/,routes/,islands/ --no-check dev.ts"
},
"importMap": "./import_map.json"
}
routes/
Next.js のようなファイルベースのルーティングを提供します。例えば routes/articles/create.tsx
に配置したファイルは /articles/create
のパスに対応しています。
また routes/
ディレクトリ中のコードはクライアントに配信されることはありません。クライアント上で動作させたいコードは islands/
ディレクトリに配置します。
islands/
islands
はもう少し先で説明する Islands Architecture に由来するディレクトリです。
このフォルダの中のコードは、クライアントとサーバーの両方から実行できます。
statis/
静的なファイルを配置するディレクトリです。このディレクトリに配置した静的ファイルは自動では配信されます。また routes/
ディレクトリも高い優先順位で提供されます。
トップページの作成
それでは早速記事投稿サイトの作成に取り掛かりましょう。routes/index.tsx
ファイルを修正します。
/** @jsx h */
import { h, PageProps } from "$fresh/runtime.ts"; // ①
import { Head } from "$fresh/src/runtime/head.ts";
import { Handlers } from "$fresh/server.ts";
interface Article {
id: string;
title: string;
created_at: string;
}
export const handler: Handlers<Article[]> = { // ②
async GET(_, ctx) {
const articles: Article[] = [
{
id: "1",
title: "Article 1",
created_at: "2022-06-17T00:00:00.000Z",
},
{
id: "2",
title: "Article 2",
created_at: "2022-06-10T00:00:00.000Z",
},
];
return ctx.render(articles);
},
};
export default function Home({ data }: PageProps<Article[]>) { // ③
return (
<div>
<Head>
<title>Fresh Blog</title>
</Head>
<div>
<h1>Fresh Blog</h1>
<section>
<h2>Posts</h2>
<ul>
{data.map((article) => (
<li key={article.id}>
<a href={`articles/${article.id}`}>
<h3>{article.title}</h3>
<time dateTime={article.created_at}>{article.created_at}</time>
</a>
</li>
))}
</ul>
</section>
</div>
</div>
);
}
①:最初の 1 行では /** @jsx h */
コメントと $fresh/runtime.ts
から h
関数を import しています。これらは JSX をレンダリングするためのボイラープレートです。React における import React from 'react'
と似たようなものだと考えてよいでしょう。
②:ルートモジュールではカスタムハンドラを作成できます。カスタムハンドラは handler
という名前で名前付きエクスポートを行う必要があります。
カスタムハンドラは第 1 引数に Request
オブジェクトを受け取り Response
オブジェクトを返す関数です。この例では GET
ハンドラを定義し ctx.render
関数に articles
配列を渡しています(今のところはベタ書きです)。ctx.render
関数に渡したデータはページコンポーネントの props.data
からアクセスできます。
③:JSX コンポーネントを作成します。ルートモジュールではコンポーネントを default export することで HTML のレンダリングを行います。なお Fresh では React ではなく Preact を使用しています。
コンポーネント内部ではカスタムハンドラから受け取った articles
配列をリストレンダリングしています。また $fresh/src/runtime/head.ts
から import した <Head>
タグを使用することでページの head
に要素を追加できます。
ここまでの内容を確認してみましょう。 http://localhost:8000/ にアクセスします。
想定通り記事の一覧が表示されていますね!
スタイリング
ただこのままの表示ですとちょっと味気ないですので、スタイルを追加しましょう。import_map.json
に以下を追加します。
{
"imports": {
"@twind": "./util/twind.ts",
"$fresh/": "https://raw.githubusercontent.com/lucacasonato/fresh/main/",
"twind/": "https://esm.sh/twind@0.16.16/",
"dayjs/": "https://esm.sh/dayjs@1.11.3/"
}
}
twind は Tailwind ライクな CSS フレームワークです。Tailwind CSS と異なりビルドステップが不要で Deno でも使用できます。
dayjs は npm でもおなじみの日付操作ライブラリです。
まずは util/twind.ts
ファイルを作成します。このファイルでは twind の設定をするとともに、必要な関数を再 export します。
import { IS_BROWSER } from "$fresh/runtime.ts";
import { apply, Configuration, setup, tw } from "twind";
export { css } from "twind/css";
export { apply, setup, tw };
export const config: Configuration = {};
if (IS_BROWSER) setup(config);
続いて routes/_render.ts
ファイルを作成します。
import { config, setup } from "@twind";
import { RenderContext, RenderFn } from "$fresh/server.ts";
import { virtualSheet } from "twind/sheets";
const sheet = virtualSheet();
sheet.reset();
setup({ sheet, ...config });
export function render(ctx: RenderContext, render: RenderFn) {
const snapshot = ctx.state.get("twindSnapshot") as unknown[] | null;
sheet.reset(snapshot || undefined);
render();
ctx.styles.splice(0, ctx.styles.length, ...(sheet).target);
const newSnapshot = sheet.reset();
ctx.state.set("twindSnapshot", newSnapshot);
}
これで twind の準備は整いました。routes/index.tsx
を修正して確認してみましょう。@twind
から tw
関数を import して使用します。
/** @jsx h */
import { h, PageProps } from "$fresh/runtime.ts";
import { Head } from "$fresh/src/runtime/head.ts";
import { Handlers } from "$fresh/server.ts";
+ import { tw } from "@twind";
// 省略...
export default function Home({ data }: PageProps<Article[]>) {
return (
<div>
<Head>
<title>Fresh Blog</title>
</Head>
<div>
- <h1>Fresh Blog</h1>
+ <h1 class={tw("text-red-500")}>Fresh Blog</h1>
<section>
<h2>Posts</h2>
<ul>
{data.map((article) => (
<li key={article.id}>
<a href={`articles/${article.id}`}>
<h3>{article.title}</h3>
<time dateTime={article.created_at}>{article.created_at}</time>
</a>
</li>
))}
</ul>
</section>
</div>
</div>
);
}
指定したスタイル(tw("text-red-500")
)が適用されていることがわかります!
Twind が使えるようになったので本格的にスタイリングを始めましょう。ここでは Twind の解説は省きますが、最終的に次のようなコードになります。
/** @jsx h */
import { h, PageProps } from "$fresh/runtime.ts";
import { Head } from "$fresh/src/runtime/head.ts";
import { Handlers } from "$fresh/server.ts";
import { tw } from "@twind";
// dayjs で相対日付を表示するために import
import dayjs from "https://esm.sh/dayjs@1.11.3";
import relativeTime from "dayjs/plugin/relativeTime";
import "dayjs/locale/ja";
dayjs.extend(relativeTime);
dayjs.locale("ja");
interface Article {
id: string;
title: string;
created_at: string;
}
export const handler: Handlers<Article[]> = {
async GET(_, ctx) {
const articles: Article[] = [
{
id: "1",
title: "Article 1",
created_at: "2022-06-17T00:00:00.000Z",
},
{
id: "2",
title: "Article 2",
created_at: "2022-06-10T00:00:00.000Z",
},
];
return ctx.render(articles);
},
};
export default function Home({ data }: PageProps<Article[]>) {
return (
<div class={tw("h-screen bg-gray-200")}>
<Head>
<title>Fresh Blog</title>
</Head>
<div
class={tw(
"max-w-screen-sm mx-auto px-4 sm:px-6 md:px-8 pt-12 pb-20 flex flex-col"
)}
>
<h1 class={tw("font-extrabold text-5xl text-gray-800")}>Fresh Blog</h1>
<section class={tw("mt-8")}>
<h2 class={tw("text-4xl font-bold text-gray-800 py-4")}>Posts</h2>
<ul>
{data.map((article) => (
<li
class={tw("bg-white p-6 rounded-lg shadow-lg mb-4")}
key={article.id}
>
<a href={`articles/${article.id}`}>
<h3
class={tw(
"text-2xl font-bold mb-2 text-gray-800 hover:text-gray-600 hover:text-underline"
)}
>
{article.title}
</h3>
</a>
<time
class={tw("text-gray-500 text-sm")}
dateTime={article.created_at}
>
{dayjs(article.created_at).fromNow()}
</time>
</li>
))}
</ul>
</section>
</div>
</div>
);
}
データベースを使用する
続いてベタ書きしていた記事一覧を Postgress データベースから取得するように修正しましょう。ここでは無料枠で 500MB までの Postgres サーバーをすぐに使うことができる Supabase を使うこととします。
Supbase でプロジェクトを作成
まずは Start your projest をクリックします。
GitHub で認可を求められるので許可する必要があります。
New Project から新しいプロジェクトを作成しましょう。
プロジェクト名とデータベースのパスワードを入力し、リージョンを選択します。
プロジェクトの作成の完了後、サイドバーの Setting → Database → Connection info からホスト名をコピーして保存しておきます。
サイドバーの Table Edier メニューを選択し、Create Table からテーブルを作成します。
次のようにテーブルを作成しました。
Name | Type | Default Value | Primary |
---|---|---|---|
id | uuid | uuid_generate_v4 | ◯ |
created_at | timestamptz | now() | |
title | text | ||
content | text |
いくつかテストデータを挿入しておきましょう。Insert row からデータを作成します。
Deno から Postgress に接続する
それでは、作成したデータベースに対して接続します。ここでは deno-postgres ライブラリを使用します。また環境変数を使用するために dotenv ライブラリも追加します。
import_map.json
に記述しましょう。
{
"imports": {
"@db": "./util/db.ts",
"postgress": "https://deno.land/x/postgres@v0.16.1/mod.ts",
"dotenv/": "https://deno.land/x/dotenv@v3.2.0/"
}
}
.env
ファイルを作成してさきほどの手順で保存しておいたパスワードとホスト名を記述します。
DB_USER=postgres
POSTGRES_DB=postgres
DB_PORT=${ポート}
DB_PASSWORD=${パスワード}
DB_HOST=${ホスト名}
util/db.ts
ファイルを作成して Postgres へのコネクションを作成します。
import { Client } from "postgress";
import "dotenv/load.ts";
/**
* articls テーブルの型
*/
export interface Article {
id: string;
title: string;
content: string;
created_at: string;
}
// DB クライアントを作成する
const client = new Client({
user: Deno.env.get("DB_USER"),
database: Deno.env.get("POSTGRES_DB"),
hostname: Deno.env.get('DB_HOST'),
password: Deno.env.get('DB_PASSWORD'),
port: Deno.env.get('DB_PORT'),
});
// データベースに接続
await client.connect();
/**
* すべての記事を取得する
*/
export const findAllArticles = async () => {
try {
const result = await client.queryObject<Article>(
"SELECT * FROM articles ORDER BY created_at DESC"
);
return result.rows;
} catch (e) {
console.error(e);
return [];
}
}
/**
* ID を指定して記事を取得する
*/
export const findArticleById = async (id: string) => {
try {
const result = await client.queryObject<Article>(
"SELECT * FROM articles WHERE id = $1",
[id]
);
if (result.rowCount === 0) {
return null
}
return result.rows[0];
} catch (e) {
console.error(e);
return null;
}
}
/**
* 記事を新規作成する
*/
export const createArticle = async (article: Pick<Article, 'title' | 'content'>) => {
try {
const result = await client.queryObject<Article>(
"INSERT INTO articles (title, content) VALUES ($1, $2) RETURNING *",
[article.title, article.content]
);
return result.rows[0];
} catch (e) {
console.error(e);
return null;
}
}
dotenv/load.ts
をインポートすることで自動的に .env
ファイルを読み込みます。Deno では Deno.env
から環境変数を読み込みます。
deno-postgres
から Client
クラスをインポートし、Postgres への接続情報をコンストラクタに渡します。そして作成した client
を利用し await client.connect()
でデータベースに接続します。
このモジュールでは以下の関数を export します。
-
findAllArticles
:すべての記事を取得する -
findArticleById
:ID を指定して記事を取得する -
createArticle
:記事を新規作成する
データベースへの問い合わせは connect.queryObject
関数で行います。型引数で Article
を指定することでクエリ結果に Article
型が付与されます。
routes/index.tsx
ファイルに戻り、カスタムハンドラをデータベースから記事を取得するように修正しましょう。
import { Article, findAllArticles } from "@db";
export const handler: Handlers<Article[]> = {
async GET(_, ctx) {
const articles = await findAllArticles();
return ctx.render(articles);
},
};
ここまでうまくいけば、データベースに保存してある記事が表示されるはずです!
記事詳細ページ
続いて、記事の詳細を取得するページを作成しましょう。Fresh は Next.js のようなファイルベースのルーティングを採用しています。
routes/articles/[id].tsx
という名前のファイルを作成すれば動的なルーティングを作成できます。早速試してみましょう。
/** @jsx h */
import { h, PageProps } from "$fresh/runtime.ts";
export default function ArticlePage(props: PageProps) {
const { id } = props.params;
return (
<div>
<h1>Article: {id}</h1>
</div>
);
}
動的に設定したパスパラメータは props.params
から取得できます。
トップページからいずれかの記事を選択してクリックすると記事詳細ページへ遷移するはずです。
確かに、記事詳細ページが作成されていますね。ここで fresh.get.ts
ファイルを確認してみると自動でルーティング情報が追加されていることが確認できます。
データベースから記事を取得するように修正しましょう。カスタムハンドラ内でパスパラメータを取得し、findArticleById
関数を呼び出します。記事を取得できない場合には Not Found
を表示します。
/** @jsx h */
import { h, PageProps } from "$fresh/runtime.ts";
import { Head } from "$fresh/src/runtime/head.ts";
import { Handlers } from "$fresh/server.ts";
import { tw } from "@twind";
import { Article, findArticleById } from "@db";
import dayjs from "dayjs";
export const handler: Handlers<Article | null> = {
async GET(_, ctx) {
// パスパラメータを取得
const { id } = ctx.params;
// パスパラメータの ID を引数に記事を取得
const article = await findArticleById(id);
// 記事が取得できなかった場合は null を渡す
if (!article) {
return ctx.render(null);
}
// 記事が取得できた場合は取得した記事を渡す
return ctx.render(article);
},
};
export default function ArticlePage({ data }: PageProps<Article | null>) {
// Props.data に null が渡された時には `Not Found` を表示する
if (!data) {
return <div>Not Found</div>;
}
return (
<div class={tw("min-h-screen bg-gray-200")}>
<Head>
<title>{data.title}</title>
</Head>
<div
class={tw(
"max-w-screen-sm mx-auto px-4 sm:px-6 md:px-8 pt-12 pb-20 flex flex-col"
)}
>
<article class={tw("rounded-xl border p-5 shadow-md bg-white")}>
<header>
<h1 class={tw("font-extrabold text-5xl text-gray-800")}>
{data.title}
</h1>
<time class={tw("text-gray-500 text-sm")} dateTime={data.createdAt}>
{dayjs(data.created_at).format("YYYY-MM-DD HH:mm:ss")}
</time>
</header>
<section class={tw("mt-6")}>
<p>{data.content}</p>
</section>
</article>
</div>
</div>
);
}
記事を取得して表示できました!
記事の内容はマークダウンで記述されているので HTML に変換して表示できるようにしましょう。import_map.json
に marked と sanitize-html を追加します。
{
"imports": {
"marked": "https://esm.sh/marked@4.0.17",
"sanitize-html": "https://esm.sh/sanitize-html@2.7.0",
}
}
マークダウン用のスタイルシートを static/article.css
に記述します。
#contents {
font-size: 16px;
color: #1f2937;
}
#contents h1 {
font-size: 3.0rem;
font-weight: 700;
border-bottom: 1px solid #ddd;
padding-bottom: 0.1rem;
margin-bottom: 1.1rem;
}
#contents h2 {
font-size: 2.25em;
font-weight: 700;
border-bottom: 1px solid #ddd;
padding-bottom: 0.1rem;
margin-bottom: 1.1rem;
}
#contents h3 {
font-size: 1.875rem;
font-weight: 700;
padding-bottom: 0.1rem;
margin-bottom: 1.1rem;
}
#contents h4 {
font-size: 1.5rem;
padding-bottom: 0.1rem;
margin-bottom: 1.1rem;
}
#contents h5 {
font-size: 1.25rem;
}
#contents h6 {
font-size: 1.125rem;
}
#contents ul {
list-style-type: disc;
}
#contents ol {
list-style-type: decimal;
}
#contents ul,
#contents ol {
padding-left: 1.5em;
margin: 1.5em 0;
line-height: 1.9;
}
#contents blockquote {
border-left: 5px solid #ddd;
font-weight: 100;
padding: 1rem;
padding-right: 0;
margin: 1.5rem 0;
}
#contents p {
margin: 0 0 1.125rem 0;
line-height: 1.9;
}
#contents img {
margin: 1.5rem auto;
}
#contents a {
color: #0f83fd;
}
#contents a:hover {
text-decoration: underline;
}
#contents table {
margin: 1.2rem auto;
width: auto;
border-collapse: collapse;
font-size: .95em;
line-height: 1.5;
word-break: normal;
display: block;
overflow: auto;
-webkit-overflow-scrolling: touch;
}
#contents th {
font-weight: 700;
color: black;
padding: .5rem;
border: 1px solid #cfdce6;
background: #edf2f7;
}
#contents td {
padding: .5rem;
border: 1px solid #cfdce6;
}
#contents code:not([class*="language-"]) {
background-color: #eee;
color: #333;
padding: 0.1em 0.4em;
}
マークダウンを変換して表示するように routes/articles/[id].tsx
を修正しましょう。カスタムハンドラ内で article
を取得した後、marked
でマークダウンをパースし、DOMPurify.sanitize
でサニタイズ処理を行います。
import { marked } from "marked";
import sanitize from "sanitize-html";
interface Data {
/** DB から取得した記事 */
article: Article;
/** パース済みのコンテンツ */
parsedContent: string;
}
export const handler: Handlers<Data | null> = {
async GET(_, ctx) {
const { id } = ctx.params;
const article = await findArticleById(id);
if (!article) {
return ctx.render(null);
}
// マークダウンをパースする
const parsed = parked(article.content);
// HTML をサニタイズする
const parsedContent = sanitize(parsed);;
return ctx.render({
article,
parsedContent,
});
},
};
コンポーネント内では <Head>
タグ内でさきほど作成した article.css
ファイルを読み込みます。パース済みの内容は dangerouslySetInnerHTML
で表示します。
export default function ArticlePage({ data }: PageProps<Data | null>) {
if (!data) {
return <div>Not Found</div>;
}
const { article, parsedContent } = data;
return (
<div class={tw("min-h-screen bg-gray-200")}>
<Head>
<title>{article.title}</title>
<link rel="stylesheet" href="/article.css" />
</Head>
<div
class={tw(
"max-w-screen-sm mx-auto px-4 sm:px-6 md:px-8 pt-12 pb-20 flex flex-col"
)}
>
<article class={tw("rounded-xl border p-5 shadow-md bg-white")}>
<header>
<h1 class={tw("font-extrabold text-5xl text-gray-800")}>
{article.title}
</h1>
<time
class={tw("text-gray-500 text-sm")}
dateTime={article.created_at}
>
{dayjs(article.created_at).format("YYYY-MM-DD HH:mm:ss")}
</time>
</header>
<section class={tw("mt-6")}>
<div
id="contents"
dangerouslySetInnerHTML={{ __html: parsedContent }}
/>
</section>
</article>
</div>
</div>
);
}
これにより、記事の内容が HTML に変換して表示されるようになりました。
記事作成ページ
最後に、記事を新しく作成できるように実装しましょう。routes/articles/create.tsx
ファイルを作成します。
/** @jsx h */
import { h } from "$fresh/runtime.ts";
export default function CreateArticlePage() {
return (
<h1>Create Post</h1>
);
}
トップページに記事作成ページへのリンクを作成しましょう。routes/index.tsx
ファイルを修正します。
<section class={tw("mt-8")}>
<div class={tw("flex justify-between items-center")}>
<h2 class={tw("text-4xl font-bold text-gray-800 py-4")}>Posts</h2>
<a
href="/articles/create"
class={tw(
"bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded-md"
)}
>
Create Post
</a>
</div>
続いて ruotes/articles/create.tsx
でコンテンツを入力できるようにフォームを追加しましょう。Fresh はデフォルトでクライアントの JavaScript は一切使用せずネイティブの <form>
要素を利用します。つまり入力中の状態を保持したり、フォームのサブミット後に e.preventDefault()
を呼び出す必要はありません。
/** @jsx h */
import { h } from "$fresh/runtime.ts";
import { Head } from "$fresh/src/runtime/head.ts";
import { tw } from "@twind";
export default function CreateArticlePage() {
return (
<div class={tw("min-h-screen bg-gray-200")}>
<Head>
<title>Create Post</title>
</Head>
<div
class={tw(
"max-w-screen-sm mx-auto px-4 sm:px-6 md:px-8 pt-12 pb-20 flex flex-col"
)}
>
<h1 class={tw("font-extrabold text-5xl text-gray-800")}>Create Post</h1>
<form
class={tw("rounded-xl border p-5 shadow-md bg-white mt-8")}
method="POST"
>
<div class={tw("flex flex-col gap-y-2")}>
<div>
<label class={tw("text-gray-500 text-sm")} htmlFor="title">
Title
</label>
<input
id="title"
class={tw("w-full p-2 border border-gray-300 rounded-md")}
type="text"
name="title"
/>
</div>
<div>
<label class={tw("text-gray-500 text-sm")} htmlFor="content">
Content
</label>
<textarea
id="content"
rows={10}
class={tw("w-full p-2 border border-gray-300 rounded-md")}
name="content"
/>
</div>
</div>
<div class={tw("flex justify-end mt-4")}>
<button
class={tw(
"bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded-md"
)}
type="submit"
>
Create
</button>
</div>
</form>
</div>
</div>
);
}
見た目は何も変哲のないフォームです。
まだサブミットした後の処理を実装していないので Create ボタンをクリックしても何も起こりません。カスタムハンドラを定義しましょう。
import { createArticle } from "@db";
import { Handlers } from "$fresh/server.ts";
interface Data {
/** バリデーションエラー情報 */
error: {
title: string;
content: string;
};
/** 前回のタイトルの入力値 */
title?: string;
/** 前回のコンテンツの入力値 */
content?: string;
}
export const handler: Handlers<Data> = {
async POST(req, ctx) {
// フォームデータの入力値を取得
const formData = await req.formData();
const title = formData.get("title")?.toString();
const content = formData.get("content")?.toString();
// タイトルまたはコンテンツどちらも未入力の場合はバリデーションエラー
if (!title || !content) {
return ctx.render({
error: {
title: title ? "" : "Title is required",
content: content ? "" : "Content is required",
},
title,
content,
});
}
const article = {
title,
content,
};
// データベースに保存
await createArticle(article);
// トップページにリダイレクト
return new Response("", {
status: 303,
headers: {
Location: "/",
},
});
},
};
カスタムハンドラに POST
メソッドを定義します。これにより POST リクエストが送信されたときにハンドラが呼び出されます。
ハンドラ内ではまず req.formData()
関数を呼び出してフォームの入力値を取り出します。title
または content
のどちらかが未入力であった場合はバリデーションエラーとして処理を中断します。ここで ctx.render
関数を呼び出す際にどの項目にエラーがあったのかの情報と、前回の入力値を引数で渡すようにしておきます。
title
と content
どちらも入力されている場合には createArticle
関数を呼び出してデータベースに保存します。その後 Response
を生成して返却しトップページへリダイレクトさせます。カスタムハンドラは ctx.render()
だけでなく Response
型であれば返り値にできます。
バリデーションエラー発生時に値を返すことにしたので、コンポーネント側も値を受け取るように修正しましょう。
export default function CreateArticlePage({
data,
}: PageProps<Data | undefined>) {
return (
// 省略...
<div class={tw("flex flex-col gap-y-2")}>
<div>
<label class={tw("text-gray-500 text-sm")} htmlFor="title">
Title
</label>
<input
id="title"
class={tw("w-full p-2 border border-gray-300 rounded-md")}
type="text"
name="title"
value={data?.title} // 前回の入力値を初期値に渡す
/>
// タイトルの入力にバリデーションエラーがあった場合表示する
{data?.error?.title && (
<p class={tw("text-red-500 text-sm")}>{data.error.title}</p>
)}
</div>
<div>
<label class={tw("text-gray-500 text-sm")} htmlFor="content">
Content
</label>
<textarea
id="content"
rows={10}
class={tw("w-full p-2 border border-gray-300 rounded-md")}
name="content"
value={data?.content} // 前回の入力値を初期値に渡す
/>
// コンテンツの入力にバリデーションエラーがあった場合表示する
{data?.error?.content && (
<p class={tw("text-red-500 text-sm")}>{data.error.content}</p>
)}
</div>
</div>
);
}
data.title
の値をタイトルの input の初期値として value
に渡します。また data.error.title
の値が存在する場合にはエラーメッセージを表示します。コンテンツ入力欄も同様に修正します。
それでは実際に試してみましょう。タイトルとコンテンツどちらも未入力の場合、フォームをサブミットした後エラーメッセージが表示されます。
フォームに正しく入力できている場合、サブミット後トップページへリダイレクトします。トップページの先頭に新しく記事が追加されていることが確認できるでしょう。
プレビューを表示する
ここまではクライアントの JavaScript を一切使用せずに実装してきました(試しにブラウザの設定から JavaScript を無効にして動かしてみてください!)Fresh は Zero runtime overhead を謳っているとおり、デフォルトではクライアントに JavaScript が送信されません。
このことはパフォーマンス上メリットが得られますが、とはいえインタラクティブな操作を提供することの制限にもなりかねません。例えば、記事の内容を入力している最終にプレビューが表示できればより便利なアプリケーションとなるでしょうが、この機能を実装するためにはクライアントに JavaScript を動作させる必要があります。
現在の多くの Web フレームワークでは、クライアントに JavaScript を提供しないか、ページ全体のレンダラーを提供するかを選択できます。
しかし、この選択はページの一部分でのみインタラクティブ性を持たせたい場合を考えると、あまり柔軟ではありません。Fresh では静的なページの中の一部分で JavaScript を与える Islands Architecture を採用しています。大半の静的なコンテンツはサーバーでレンダリングし、動的な部分のみをプレースホルダーに挿入するというシンプルな考え方です。
その他 Islands Architecture を採用している Web フレームワークに Astro が存在します。
Fresh では Islands Architecture を実現するために islands/
ディレクトリを使用しています。このフォルダ内のモジュールは、それぞれ 1 つの Islands コンポーネントをカプセル化します。またこのモジュールのファイル名はパスカルケースとする必要があります。
それでは実際に Islands コンポーネントを作成しましょう。islands/ContentForm.tsx
ファイルを作成します。
/** @jsx h */
import { h, useState } from "$fresh/runtime.ts";
import { tw } from "@twind";
import { marked } from "marked";
import DOMPurify from "dompurify";
interface Props {
initialValue?: string;
}
export default function ContentForm({ initialValue = "" }: Props) {
// コンテンツの入力値を保持する
const [value, setValue] = useState(initialValue);
// プレビュー表示するかどうかの状態
const [preview, setPreview] = useState(false);
/**
* マークダウンをパースする関数
*/
const parse = (content: string) => {
const parsed = marked(content);
const purified = DOMPurify.sanitize(parsed);
return purified;
};
const handleChange = (e: Event) => {
const target = e.target as HTMLTextAreaElement;
setValue(target.value);
};
return (
<div>
<div class={tw("flex justify-between")}>
<label class={tw("text-gray-500 text-sm")} htmlFor="content">
Content
</label>
<label class={tw("text-gray-500 text-sm")}>
Preview
<input
type="checkbox"
id="preview"
class={tw("ml-2")}
checked={preview}
onChange={() => setPreview((prev) => !prev)}
/>
</label>
</div>
// preveiw が true の場合パース済のコンテンツを表示
// それ以外の場合にはコンテンツの入力フォームを表示
{preview ? (
<div
id="contents"
dangerouslySetInnerHTML={{
__html: parse(value),
}}
/>
) : (
<textarea
id="content"
rows={10}
class={tw("w-full p-2 border border-gray-300 rounded-md")}
name="content"
value={value}
onChange={handleChange}
/>
)}
</div>
);
}
大まかな部分はもともとのコンテンツ入力フォームと同じです。
はじめに Props として値の初期値を受け取ります。大きな違いはフォームの入力値を useState
で保持するように変更したところです。状態を保持するように変更したことで、入力値が変更されるたびにプレビューの表示を更新させることができます。
useState
で preview
という状態も保持しており、この値はチェックボックスにより操作されます。preview
が true
の場合には入力値のプレビューを表示し、false
の場合には入力フォームを表示するようにしました。
続いて routes/articles/create.tsx
において ContentForm
コンポーネントを利用するように修正します。またプレビューを表示させるので article.css
を読み込む必要があります。
export default function CreateArticlePage({
data,
}: PageProps<Data | undefined>) {
return (
<div class={tw("min-h-screen bg-gray-200")}>
<Head>
<title>Create Post</title>
+ <link rel="stylesheet" href="/article.css" />
</Head>
<div
class={tw(
"max-w-screen-sm mx-auto px-4 sm:px-6 md:px-8 pt-12 pb-20 flex flex-col"
)}
>
<h1 class={tw("font-extrabold text-5xl text-gray-800")}>Create Post</h1>
<form
class={tw("rounded-xl border p-5 shadow-md bg-white mt-8")}
method="POST"
>
<div class={tw("flex flex-col gap-y-2")}>
<div>
<label class={tw("text-gray-500 text-sm")} htmlFor="title">
Title
</label>
<input
id="title"
class={tw("w-full p-2 border border-gray-300 rounded-md")}
type="text"
name="title"
value={data?.title}
/>
{data?.error?.title && (
<p class={tw("text-red-500 text-sm")}>{data.error.title}</p>
)}
</div>
<div>
- <textarea
- id="content"
- rows={10}
- class={tw("w-full p-2 border border-gray-300 rounded-md")}
- name="content"
- />
+ <ContentForm initialValue={data?.content} />
{data?.error?.content && (
<p class={tw("text-red-500 text-sm")}>{data.error.content}</p>
)}
</div>
</div>
<div class={tw("flex justify-end mt-4")}>
<button
class={tw(
"bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded-md"
)}
type="submit"
>
Create
</button>
</div>
</form>
</div>
</div>
);
}
最後に import_map.json
に dompurify を追加します。(`sanitize-html`` をクライアントで使用できなかったので追加しています)
{
"imports": {
"dompurify": "https://esm.sh/dompurify@2.3.8"
}
}
それでは試してみましょう、フォームの入力に応じてプレビューが変化することがわかります!
デプロイ
最後に作成したアプリケーションをデプロイしましょう!Fresh のデプロイ先として Deno Deploy があげられます。Deno Deploy は Deno の開発元である Deno land 社により提供されているホスティングサービスです。グローバルに分散したエッジランタイムで、開発者が迅速かつ容易にインターネットにウェブアプリケーションをデプロイできます。
Deno Deploy にアクセスして Sing up をクリックします。ここでは GitHub の認可が求められます。
New Project から新しいプロジェクトを作成します。
デプロイの方法として、GitHub レポジトリと連携するか、オンラインエディタで編集したファイルを利用するか選択できます。ここでは GitHub レポジトリと連携する方法を選択します。Env Variables も忘れずに追加しておきましょう。
エントリーファイルには main.ts
を選択します。
Link をクリックして少し待つとデプロイが完了します!
おわりに
Deno の Web フレームワークである Fresh を使ったチュートリアルを紹介しました。
deno.land は Fresh で実装されているようなので、deno.land のソースコードを参考に実装すると良さそうです。
以下の記事も大変参考になりました。
Discussion