JSONを読んでトップページのニュースを更新する (with Vue)

この記事は [ここのえ Advent Calendar 2023](https://qiita.com/advent-calendar/2023/99no_exit) Day 23 の記事です。

# JSON形式の新着情報を、整形してVueで表示する

個人サイト・会社サイト問わず大抵のWebサイトでは、「お知らせ」や「プレスリリース」といったセクションを置きがちです（要出展）。

そんなお知らせセクションですが、他のWebサイトのコンテンツと異なり、何かイベントが発生するたびに頻繁に更新したいので、可能なら **JSON** で書いておいて、すぐ修正できるようにしておきたいです。

そんな訳で、[**Vue**](https://ja.vuejs.org/) ベースの自分のサイトに乗せることにしました。
折角自分のサイトなので、以下2点を満たすように実装してみます。

- `<v-html>`は使わない
    - 今回の用途では問題ないといえばそうだが、やはりHTMLを直書きできるのはXSSなどの心配もあり、避けたい気持ちがある
- `sanitize-html` も避けたい
    - 極力ライブラリを大きくしたくないし、ユーザの入力を表示するわけでもないから大げさすぎてしまう

という事で、VueでJSONを読んで作る例です。

# デザインのサンプル

こんな感じのデザインを目指します。

- Desktop

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3617021/8d9dadf9-98b2-69c8-40aa-dc022914d490.png)

- Smartphone

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3617021/94581166-4dbb-9e6c-c7c1-7c0da74951c2.png)

# 実装

`Vue + Typescript` の構成になります。
実際には `Pinia` を使っていますが、なくても大丈夫なように簡略化します。
デザインは各自CSSを用意してください。

以下、全体の流れです。

- LaravelやRailsなどから、Vueの`props`にデータが飛んでくる (`NewsProp`)
- Vueの表示系で使うときの形式に変換 (`NewsArticle`)
- 日付を基準として、降順に並び替え
- 飛んできたデータのうち、未来の日付のニュースは表示しない

## 用意するJSONと各種データ構造

使用するデータの型は、以下の3つを使います。

```NewsStore.ts
export interface NewsProp {
  type: 'notice' | 'event' | 'release'  // ニュースのタイプ
  date: string  // 日付 ex."2023/12/34"
  contents: NewsArticleContent[]
}

export interface NewsArticle {
  type: string  // ニュースのタイプ(表示用に変換後。"お知らせ"など)
  icon: string[]  // <font-awesome-icon>に使う
  date: Date  // ソート比較用
  date_texts: {  // 表示用のテキスト
    year: string
    month_day: string
  }
  contents: NewsArticleContent[]
}

export interface NewsArticleContent {
  text: string // 内容
  utils?: string // Tailwind class
  line?: boolean // 改行
  link?: string // <a>のhref
}
```

- `NewsProp`
    - `prop`で渡されるJSONデータの形式
- `NewsArticle`
    - 表示に使用するデータ形式
- `NewsArticleContent`
    - ニュースに表示するテキストの中身。

`NewsArticleContent`ですが、ここにHTMLを入れてしまうと`v-html`を使うのであればそのまま書き出せますが、今回はなしで実装したいので `<span>`タグ別に配列として入れる形にしています。
また`link`がある場合は`<a>`タグとして取り扱いたいですが、これは要素の有無を見てVue側で判断するようにします（後述）。

このデータ型を踏まえたうえで、例えば以下のようなJSONが飛んできます。

```news.json
[
  {
    "type": "event",
    "date": "2023/12/01",
    "contents": [
      {
        "text": "QiitaでAdvent Calendar 2023に参加しています。",
        "line": true
      },
      {
        "text": "カレンダーは "
      },
      {
        "text": "こちら",
        "link": "https://qiita.com/advent-calendar/2023/99no_exit"
      },
      {
        "text": " 。"
      }
    ]
  }
]
```

この `contents` は、最終的に以下のHTMLを出力します。

```sample.html
<span>QiitaでAdvent Calendar 2023に参加しています。</span>
<br/>
<span>カレンダーは </span>
<a href="https://qiita.com/advent-calendar/2023/99no_exit">こちら</a>
<span> 。</span>
```

## JSONを表示系で使える形式に変換

```ts
const articles = [] as NewsArticle[]

const addNews = (newsData: NewsProp[]) => {
  for (const article of newsData) {
    let type: string
    let icon: string[]

    if (article.type === 'notice') {
      type = 'お知らせ'
      icon = ['fas', 'rss']
    } else if (article.type === 'event') {
      type = 'イベント'
      icon = ['fas', 'calendar']
    } else if (article.type === 'release') {
      type = 'リリース'
      icon = ['fas', 'star']
    }

    const date = new Date(article.date)
    const date_str = article.date.split('/')

    articles.push({
      type,
      icon,
      date,
      date_texts: {
        year: date_str[0],
        month_day: `${date_str[1]}.${date_str[2]}`,
      },
      contents: article.contents,
    })
  }
}
```

`JSON.parse()`したものを`addNews`の引数として取ることを想定します。

`articles` 配列を用意して、飛んできたJSONを変換して入れています。先程作ったデザインサンプルを再現するために、`year`と`month, day` を分けて、予めstringにして入れています。

`icon`は`<font-awesome-icon>`を使っているので、これに対応した配列を用意しています。

```vue
<!-- RSSアイコンの例 -->
<font-awesome-icon :icon="['fas', 'rss']" />
```

## ソート・時刻によるフィルタ

特に解説することもないのですが、Array.sortでソートしていきます。
```ts
this.articles.sort((a, b) => {
  const n = a.date.getTime() - b.date.getTime()
  if (n > 0) {
    return -1
  } else if (n < 0) {
    return 1
  } else {
    return 0
  }
})
```

時刻によってフィルターをかけ、未来の日付のものを取得します。
厳密にはデータとして飛んできている時点でバレてしまうのですが……今回はそこまで厳密である必要はないので、クライアント側だけで表示を処理しています。
必要に応じて、Laravel等のフレームワーク側で送る前に消すようにしてください。

```ts
const getNews = (): NewsArticle[] => {
  const now = new Date()
  return articles.filter((article: NewsArticle) => article.date <= now)
},
```

## Vueコンポーネントの実装

**ここからが本題**です。

記事の表示部分に焦点を絞って紹介します。
完成系のサンプルを再掲しておきます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3617021/40967137-b2b3-4f9e-04c1-ed25732e4e34.png)


```vue
<div v-for="(article, index) in articles" :key="index" class="article">
  <div class="badge">
    <span>{{ article.type }}</span>
    <font-awesome-icon :icon="article.icon" size="xl" />
  </div>
  <div class="date">
    <div class="y">{{ article.date_texts.year }}.</div>
    <div class="md">{{ article.date_texts.month_day }}</div>
  </div>
  <div class="divider">
    <svg viewBox="0 0 10 50">
      <line x1="10" y1="0" x2="0" y2="50" />
    </svg>
  </div>
  <div class="text">
    <template v-for="text in article.contents" :key="text">
      <a v-if="text.link !== undefined" :href="text.link" :class="text.utils">{{ text.text }}</a>
      <span v-else :class="text.utils">{{ text.text }}</span>
      <br v-if="text.line" />
    </template>
  </div>
</div>
```

### 全体

```vue
<div v-for="(article, index) in articles" :key="index" class="article">
```

`articles`配列を`v-for`で回しています。
keyをindexにするかどうか？については各種論争があると思いますが、今回のケースではアクセスした際に一緒にpropsが降ってきて、それ以降は一切変動しないので`index`をそのまま使っています。

`axios`などを使って動的に取得するのであれば、各ニュースにIDを振っておきkeyにした方が良いです。

### badge

```vue
<script setup lang="ts">
import { faRss, faCalendar, faStar } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { library } from '@fortawesome/fontawesome-svg-core'
library.add(faRss, faCalendar, faStar)
</script>

...

<div class="badge">
  <span>{{ article.type }}</span>
  <font-awesome-icon :icon="article.icon" size="xl" />
</div>
```

ジャンルによってテキストもアイコンも変更したいので、`NewsArticle`の`type`, `icon`を見て生成させます。
同じように`vue-fontawesome`を使っている場合は、使うアイコンをimportして`library.add`するように気を付けます。

:::note info
`@fortawesome/vue-fontawesome` の導入は [**FontAwesomeのドキュメント**](https://fontawesome.com/docs/web/use-with/vue/) を参考にしてください。
アイコンのスタイリングに関する詳細は [**こちら**](https://fontawesome.com/docs/web/use-with/vue/style)。
:::

:::note warn
本筋と外れますが、Font Awesomeのアイコンは **微妙に同一サイズになっていません**。
CSSを取り扱うときは、アイコンのwidth/heightを固定にしたり、コンテナのサイズを固定幅にしたりする工夫が必要です。
```tailwindのsample.sass
.badge
  @apply flex justify-center items-center w-[135px]
```
:::

## テキストの表示部

```vue
<div class="text">
  <template v-for="text in article.contents" :key="text">
    <a v-if="text.link !== undefined" :href="text.link" :class="text.utils">{{ text.text }}</a>
    <span v-else :class="text.utils">{{ text.text }}</span>
    <br v-if="text.line" />
  </template>
</div>
```

中心となるポイントです。`NewsArticle.contents`の分だけループして、`<a>`や`<span>`要素を生成します。
中身を見るまで何のタグを当てるのか判断がつかないので、`<template v-for>`で回す[^1]ことで余分なDOM要素が生成されないように工夫をしています。

`<a>`タグと`<span>`タグの判定については、先述の通り`link`のURLの有無で判断してタグをつけます。linkはOptionalになっているので、存在しない場合はundefinedになるため、上記のような判定が通ります。

Tailwindを使っているので、`text.utils`でCSSユーティリティを当てるための仕組みも一応用意しています。
普通にクラスを当てたい場合も同じ原理で出来ますが、分かりやすいように変数名を変えておいた方が良いと思います。ただ`class`は予約語なので使えないのが、毎度頭を悩ませるところですね……

改行の処理については素直な感じで、`line:true`であれば`<br/>`を書き込むようにしています。

# あとがき

表示用のデータ構造については、デザインによる都合もあると思います。
その辺りは用途に応じていい感じにしてみてください。

今回の例ではテキストを載せるだけだったのでこういった実装にしました。
これ以上の規模になってくるのであれば普通にサニタイザを使った方が賢明ですね。

# おまけ Laravel → Vueにpublicフォルダ内のJSONを投げる

```SampleController.php
return view("sample", [
    "news" => json_decode(file_get_contents(public_path("json/news.json"), true))
]);

```

```sample.blade.php
<div id="app">
    <index-page :news="@js($news)" />
</div>
```

`public`フォルダ内のJSONをVueに渡す方法です。

`json_decode`したものを`blade`の中で、`@js`ディレクティブを使って渡すだけです。シンプル。


[^1]:https://v2.ja.vuejs.org/v2/guide/list#lt-template-gt-%E3%81%A7%E3%81%AE-v-for