テンプレートの部品化

今後、製作が進むにつれて、再利用可能なHTMLが出てくるかも知れません。また、HTMLファイルが数百行に膨れ上がるかも知れません。そこで、今のうちに、HTMLを分割して管理する方法を学んでおきましょう。

ツイート表示の切り出し

ツイートの表示部分は、ホーム画面やユーザー検索結果画面など、今後さまざまなページで使うことが予想されます。よって、この部分をページの部品として切り出します。

templates/tweet.html
{% macro render(tweet) %}
<div class="tweet">
  <span class="name">{{tweet.name}}</span>
  <div>{{tweet.message}}</div>
  <div class="posted_at">{{tweet.posted_at}}</div>
</div>
{% endmacro %}

このテンプレートに対応する構造体を定義します。といっても、今までの IndexViewTweetView に変更し、 pathtweet.html に変更するだけです。

struct TweetView {
    name: String,
    message: String,
    posted_at: String,
}

複数ツイートへの対応

ツイートは複数表示する想定のため、このタイミングで複数のツイートを取り扱うように IndexView を変更しておきます。

#[derive(Template)]
#[template(path = "index.html")]
struct IndexView {
    tweets: Vec<TweetView>,
}

struct TweetView {
    name: String,
    message: String,
    posted_at: String,
}

複数のツイートを取り扱うように変更したため、メインのコードもそれに合わせて変更します。

src/main.rs
use askama::Template;
use axum::error_handling::HandleErrorExt;
use axum::{
    body::{Bytes, Full},
    http::{Response, StatusCode},
    response::{Html, IntoResponse},
    routing::{get, post, service_method_routing},
    Router,
};
use std::{convert::Infallible, net::SocketAddr};
use tower_http::services::ServeDir;

#[tokio::main]
async fn main() {
    let app = Router::new()
        .nest("/static", static_contents())
        .route("/", get(index));
    let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
    println!("listening on {}", addr);
    axum::Server::bind(&addr)
        .serve(app.into_make_service())
        .await
        .unwrap();
}

async fn index() -> impl IntoResponse {
    HtmlTemplate(IndexView {
        tweets: vec![
            TweetView {
                name: "太郎".into(),
                message: "こんにちは!".into(),
                posted_at: "2020-01-01 12:34".into(),
            },
            TweetView {
                name: "次郎".into(),
                message: "こんにちは!こんにちは!!".into(),
                posted_at: "2020-01-02 12:34".into(),
            },
        ],
    })
}

fn static_contents() -> Router {
    Router::new().nest(
        "/",
        service_method_routing::get(ServeDir::new("static")).handle_error(
            |error: std::io::Error| {
                (
                    StatusCode::INTERNAL_SERVER_ERROR,
                    format!("Unhandled internal error: {}", error),
                )
            },
        ),
    )
}

#[derive(Template)]
#[template(path = "index.html")]
struct IndexView {
    tweets: Vec<TweetView>,
}

struct TweetView {
    name: String,
    message: String,
    posted_at: String,
}

struct HtmlTemplate<T>(T);

impl<T> IntoResponse for HtmlTemplate<T>
where
    T: Template,
{
    type Body = Full<Bytes>;
    type BodyError = Infallible;

    fn into_response(self) -> Response<Self::Body> {
        match self.0.render() {
            Ok(html) => Html(html).into_response(),
            Err(err) => Response::builder()
                .status(StatusCode::INTERNAL_SERVER_ERROR)
                .body(Full::from(format!(
                    "Failed to render template. Error: {}",
                    err
                )))
                .unwrap(),
        }
    }
}

TweetView テンプレートを参照し、複数のツイートを表示するようにHTMLとCSSを改修します。

templates/index.html
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>Rustwi</title>
  <link rel="stylesheet" href="/static/css/index.css">
</head>
<body>

{% import "tweet.html" as tweet %}
{% for t in tweets %}
{% call tweet::render(t) %}
{% endfor %}

</body>
</html>
static/css/index.css
.tweet {
  border: 1px solid gray;
}

.tweet:not(:last-child) {
  margin-bottom: 12px;
}

.name {
  font-weight: bold;
  border-bottom: 1px solid gray;
  border-right: 1px solid gray;
}

.posted_at {
  font-size: 80%;
  color: gray;
}