20200225のvue.jsに関する記事は8件です。

Vuetifyを入れようとするとMissing file extension "vue" for "./components/HelloWorld"が発生する

発生した環境

  • @vue/cli 4.1.2
  • vue-cli-plugin-vuetify@2.0.5

発生したエラー

npx @vue/cli add vuetifyでvuetifyをvueプロジェクトに追加しようとすると補完Hooks実行中に以下のエラーが発生した。

?  Invoking generator for vue-cli-plugin-vuetify...
⠋  Running completion hooks...error: Missing file extension "vue" for "./components/HelloWorld" (import/extensions) at src/App.vue:47:24:
  45 | 
  46 | <script>
> 47 | import HelloWorld from './components/HelloWorld';
     |                        ^
  48 | 
  49 | export default {
  50 |   name: 'App',

解決した方法

このissueを参考に、eslintのrule設定にimport/extensionsを追加


"rules": {
      "import/extensions": [
        ".js",
        ".jsx",
        ".mjs",
        ".ts",
        ".tsx",
        ".vue"
      ]
    }
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Rails & JS系(Vueとか)のフレームワークで未来の時間のテストをする方法

Rails側(サーバサイド)

Timecopを導入。
とりあえずGemfileに1行挿入。

Gemfile
(前略)
group :development, :test do
  (中略)
  gem 'timecop' #この一行をdevelopmentとかstagingの中に書く
  (後略)
end

Staging環境の場合は、当然staging.rbに記述してください。

config/environments/development.rb
Rails.application.configure do
(中略)
  config.after_initialize do
    t = Time.local(2023, 3, 25, 10, 5, 0) # 2023年3月25日10時5分0秒に固定
    Timecop.travel(t)
  end
end

JS側(クライアントサイド)

力技。OSの時間設定をいじれ!

取り敢えずMacだとシステム環境設定から。

スクリーンショット 2020-02-25 22.07.11.png

こんな時に便利

予定されたリリースをシミュレートしたり、月初の処理が適切に行われているか等の確認に使える。
翌月にならないとテストできない、リリース当日のぶっつけ本番は避けたいですよね。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Vue.js切り替えタブを、一つのコンポーネントに書く

基礎から学ぶVue.jsの切り替えタブを、作り替えたい

参考にするお手本

■基礎から学ぶVue.js 切り替えタブ実装例
https://cr-vue.mio3io.com/examples/tab.html#%E3%83%87%E3%83%A2

Screen Shot 2020-02-25 at 16.10.54.png

上記の実装例では、
・index.vueという親コンポーネント、
・TabItem.vueという子コンポーネント
2つ.vueファイルに分けて切り替えタブを実装しています。

しかし今回の目的は

1つのファイルにまとめて切り替えタブを作っていきます。
つまるところ、index.vueだけで済むように作り替えていきます。

作り替えてみた、完成版

index.vue
<template>
  <div class="example">
    <div class="tabs">
      <template v-bind:value="currentId">
        <button
          v-for=" item in list"
          :key="item.id"
          @click="idClickHandler(item)"
          :class="[active(item.id), 'tab']"
        >{{ item.label }}</button>
      </template>
    </div>
    <div class="contents">
      <transition>
        <section class="item" :key="currentId">{{ current.content }}</section>
      </transition>
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      currentId: 1,
      list: [
        { id: 1, label: "Tab1", content: "コンテンツ1" },
        { id: 2, label: "Tab2", content: "コンテンツ2" },
        { id: 3, label: "Tab3", content: "コンテンツ3" }
      ]
    };
  },
  computed: {
    active() {
      return function(id) {
        return this.currentId === id ? "active" : false;
      };
    },
    current() {
      return this.list.find(el => el.id === this.currentId) || {};
    }
  },
  methods: {
    idClickHandler(item) {
      return (this.currentId = item.id);
    }
  }
};
</script>

<style scoped>
.tab {
  border-radius: 2px 2px 0 0;
  background: #fff;
  color: #311d0a;
  line-height: 24px;
}
.tab:hover {
  background: #eeeeee;
}
.active {
  background: #f7c9c9;
}

.contents {
  position: relative;
  overflow: hidden;
  width: 280px;
  border: 2px solid #000;
}
.item {
  box-sizing: border-box;
  padding: 10px;
  width: 100%;
  transition: all 0.8s ease;
}
/* トランジション用スタイル */
.v-leave-active {
  position: absolute;
}
.v-enter {
  transform: translateX(-100%);
}
.v-leave-to {
  transform: translateX(100%);
}
</style>



変更したところ

  • (親)v-modelと(子)$emitを使ってイベントと値を渡していた → v-modelを廃止して、@click後の処理をmethodsに書いた
  • (子)で使えていた算出プロパティ(computed)内の、this.idを使ったactiveクラス → computedでは引数が使えないため、function関数をそのまま返す処理に変更して引数を使えるようにした

気をつけるところ

computed内、methods内で使用する値は、当たり前ですが変更する箇所が多いです。
TabItem.vueで使用する際になぜ this.値 と使えてたかというと、propsで値をもらっていたことでdataオプションのように使用できた為です。
そのため、index.vue内で this.値 を使用したくても出来ません。
v-forで回したlist内のオブジェクトitemを選択しているよ〜と伝える必要があります。
それを常に引数で渡す!!ことを心がけると上手くいきます。
ちなみに、template(html)で指定する引数とscript内で指定する引数は名前が違っても構いません。が、分かりやすくはしてください。

JSの機能か?Vueの機能か?

私は常にこの2つで迷って、正解を見つけ出すのに時間かかったりしてました。
Vue歴もJS歴も浅い時には、つねに2つのドキュメントを行き来するのではないかなと思います。
その中で迷ったことはこちらです。

・current()computedで使用するelは、何なんだろう。

■MDN find()メソッド
https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Array/find
Screen Shot 2020-02-25 at 17.20.20.png

なるほど、JSの機能なのですね。

以上

ありがとうございました。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

DjangoRestFrameWork + SendGridでお手軽お問い合わせフォームを爆速で作る

はじめに

私が個人Webサービスを作っている上での作業メモ。技術書典用のネタの下書きでもある。
フロントはVue.jsです。そのうちNuxtの方に移行したいですが・・・関係ないので省略します。

お問い合わせフォームって?

まずこんな画面があって・・・
image.png

で、これです。これの事を言ってます。割といろんなサイトで見かけますよね。
image.png

このお問い合わせフォームからメール配信サービスであるSendGridを経由して、このフォームの内容を管理人にメールで発信しよう、というのがこの記事の主題です。フロント側の方のコードは特に書く必要が無いと考えていますので、今回の記事では省略いたします。

また、SPFやDKIM等の送信ドメイン認証周りの設定方法も省略とさせてください。書いてる人もその辺り勉強中なので・・・。

今回したい事の図

雑な図で恐縮です。
image.png
流れとしては
1.フロント側でDjangoで設定したエンドポイントに向かってフォームデータをPost
2.Django側はPostされたデータをシリアライザで整形、及び検証した後にDBにInsertして、SendGrid側にパラメータ指定でPostする
3.無事管理人に届く
という感じになります。サーバーの責務多いです。

SendGridを使う

Sendgridって?

クラウド型メール配信サービスです。SMTPサーバーを立てる必要もなくAPIベースでのメール送信が可能なので、ITインフラ赤ちゃんでも簡単に自プロダクトにメール配信機能を盛り込む事ができます。料金体系も安くて個人開発者もにっこり。

利用について

SendGridはAzureから経由して使うパターン、Herokuのアドオンから経由して使うパターン、自分で申請して登録するパターン等々・・・色々ありますよね。

余談ですがFreeプランの利用枠や料金体系が結構パターン別に違ったりするので、個人開発者の方々は留意した方がいいかもしれません。本当に結構違いますよ。

SendGridの契約先による、機能面やサポート面での違い
https://sendgrid.kke.co.jp/blog/?p=10084

DynamicTemplatesについて

簡単に言うと、SendGrid側でHTMLメールのテンプレートを用意してくれる機能です。そのまんまですね。
image.png
バージョニング機能、パラメータ設定機能などなど・・便利な機能盛りだくさんです。後段で説明します。

個人的には開発者のコーディング品質向上の観点上、非常に重要な機能だと考えています。それも後で説明しますね。

DjangoRestFrameWorkについて

流石に省略します。RestAPIマンです。

コード群

シリアライザ部分は特殊な部分が特にないので省略します。

モデル

フォームのオプション部分はIntEnumで対応させてます。また、emailフィールドではユーザーの主キーを外部参照しています。

class InfromOption(IntEnum):
  UIのクソさについて = 1
  機能追加要望 = 2
  不具合全般 = 3
  規約等 = 4
  その他 = 5

  @classmethod
  def pick_v(cls):
     return [(n.name,n.value) for n in cls]

  @classmethod
  def get_enum_name(cls,key): return cls(key).name.title()

class Inform(models.Model):
    class Meta:
        db_table='inform'

    inform_id =models.AutoField(primary_key=True,null=False)

    inform_option=models.IntegerField(
        verbose_name='オプション',
        choices=InfromOption.pick_v(),
        null=True
        )
    inform_rating=models.CharField(verbose_name='評価',max_length=50,null=True)
    inform_subtitle=models.CharField(verbose_name='件名',max_length=50,null=True)
    inform_body=models.TextField(verbose_name='内容',blank=True,null=True,max_length=1000)
    email =models.ForeignKey(
        get_user_model(),
        related_name='informuser',
        on_delete=models.PROTECT,
        null=True
    )

ビュー

匿名ユーザーは空にする、ぐらいですかね。

class InformApiView(views.APIView):
    def post(self,request,*args,**kwargs):
        serializer = InformSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)

        #anonymous判定
        req_user = request.user if request.user is not None else None
        serializer.save(email = req_user)

        posttingObj =serializer.validated_data
        posttingObj['email'] = req_user.email if req_user is not None else None

        UnitWorkService.informPostMail(posttingObj)

        return Response(status.HTTP_201_CREATED)

サービスロジック

型チェック等マジで何もやってないですが動きます。

自分でリクエストパラメータ用のpersonalizationsオブジェクト整形処理したりするの結構めんどくさいです。
ので、サードパーティのSendGridのpython用ライブラリが折角用意されていますので、ありがたく使わせてもらいましょう(´・ω・`)
https://github.com/sendgrid/sendgrid-python

環境変数等はよしなに設定するとよいでしょう。

import environ
from sendgrid import SendGridAPIClient
from sendgrid.helpers.mail import (
    Mail, From, To, TemplateId ,DynamicTemplateData
    )
class UnitWorkService:
    """ 
   他にはConohaオブジェクトストレージに画像投げたりパスワードの暗号化するクラスへの橋渡し用のクラスです。
  主題に関係ない部分は省略します。
   """
    @staticmethod
    def informPostMail(inform_data):
        InformPosttingMail.sendMail(
            info_option= InfromOption.get_enum_name(inform_data['inform_option']) if 'inform_option' in inform_data else "",
            info_rating=inform_data['inform_rating']  if 'inform_rating' in inform_data else "",
            info_subject=inform_data['inform_subtitle']  if 'inform_subtitle' in inform_data else "",
            info_body=inform_data['inform_body']  if 'inform_body' in inform_data else "",
            info_user=inform_data['email']  if 'email' in inform_data else ""
            )

env=environ.Env()

class BaseSendMail(object):
    '''
    Sendgridの基底クラス
    '''

    def __init__(self):
        self.__message = Mail()

    @property
    def message(self):
        return self.__message

    @message.setter
    def message(self,value):
        name, data ,*_ = value
        setattr (self.__message, name, data)

    @staticmethod
    def lissttingParam(cls,**param):
     """
        複数入れる場合に使う
        """
        return [cls(v,*_) for v,*_ in param['data']]

    def posttingMail(self):
        try:
            sg = SendGridAPIClient(env('SENDGRID_API_KEY'))
            response = sg.send(self.message)

        except Exception as e:
            print(e)

        finally:
            return response.status_code

class GlobalPosttingMail(BaseSendMail):
    def __init__(self):
        super().__init__()
        self.message = 'from_email',From('hogehogehoge','ほげほげほげ')

class InformPosttingMail(GlobalPosttingMail):
    def __init__(self):
        super().__init__()

        self.message = 'template_id',TemplateId(template_id='hogehogehogehoegheoghoeghoge')
        self.message = 'to', self.lissttingParam(To,data=[(env('APP_MANAGE_EMAIL'),'ほげほげほげ')])

    @property
    def dyna_param(self):
        '''
        dynamic_template定義のパラメータ
        '''
        return self.__dyna_param

    @dyna_param.setter
    def dyna_param(self,value):
        self.__dyna_param = {
                'info_option':value['info_option'],
                'info_rating':value['info_rating'],
                'info_subject':value['info_subject'],
                'info_body':value['info_body'],
                'info_user':value['info_user']
            }

    @classmethod
    def sendMail(cls,**param):
        infoS = cls()
        infoS.dyna_param = param

        infoS.message = 'dynamic_template_data', DynamicTemplateData(dynamic_template_data= infoS.dyna_param)

        return infoS.posttingMail()

SendGridのDynamicTemplatesを設定する

上記のコードで以下のようなパラメータを成型してますが、このデータ群はDynamicTemplatesのレンダリングに利用します。プレースホルダーにも適用されます。

    @property
    def dyna_param(self):
        '''
        dynamic_template定義のパラメータ
        '''
        return self.__dyna_param

    @dyna_param.setter
    def dyna_param(self,value):
        self.__dyna_param = {
                'info_option':value['info_option'],
                'info_rating':value['info_rating'],
                'info_subject':value['info_subject'],
                'info_body':value['info_body'],
                'info_user':value['info_user']
            }

ご覧の通りマスタッシュ記法で指定するパラメータを書けばよいです。
image.png

動作検証

フロントでフォームで問い合わせを発射する

image.png
image.png
はい、届いてますね~
image.png

最後の方説明が超雑になりましたが、何か聞きたい事等があればお知らせください。

よろしくお願いいたします。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

顔認識していろんなリップを試せるアプリの開発と挫折

概要

作ったものはこちら(https://touchlip.koatech.info/)

対面カメラやアップロードした画像に対し、色々なリップを合わせることができます。
こういったソリューションはPerfect社さんが圧倒的ですが、再発明してみました。

Perfectさんの製品は、CHANELのサイトを見ていただければ明らかですが、ラメやマット感など、リップによって異なる質感をうまく再現しています。僕が作ったのは残念ながら色を塗るだけです。

face-api.jsを使って顔認識も描画も全部クライアントサイドで完結しています。
Netlifyの無料枠がまだまだ余裕なので運用コストも無料です。

以下、頑張ったところをメモ。

リップを塗る処理

  1. 顔認識して顔のパーツのランドマークを取る
  2. どこにLandmarksのindex何番が表示されているのかを特定する

  3. どうlandmarksを繋げば綺麗に唇を描けるかを特定する

  4. canvas上で線を結んで塗りつぶす

1はface-api.jsがやってくれるので2以降を自力で作る必要があります。

const mouthLandmarks = [
  {x: 275.4868933089642, y: 330.9715563375305},
  {x: 282.46803207477615, y: 325.7225076276611},
  {x: 292.1396419831185, y: 320.55676439588774},
  {x: 299.4580142327218, y: 321.12185386007536},
  {x: 306.25136206826255, y: 317.9190892297577},
  {x: 321.9393919535546, y: 321.63006702249754},
  {x: 337.6620153017907, y: 323.5335417110275},
  {x: 325.27621345004127, y: 332.3888464290451},
  {x: 315.2544691391854, y: 338.6371144372772},
  {x: 304.66151045044944, y: 341.4427271205734},
  {x: 295.3428850599198, y: 341.4085681754898},
  {x: 285.4118543394475, y: 338.5544467288803}
]
const __fill = (canvasCtx, points) => {
  canvasCtx.beginPath()
  canvasCtx.moveTo(points[0].x, points[0].y)
  points.splice(0, 1)
  points.forEach((point, i) => {
    // pointを結ぶ線を引く。fill()で塗りつぶされる
    canvasCtx.lineTo(point.x, point.y)
    // pointの座標にindexの数字を配置する(デバッグ用)
    canvasCtx.fillText(i, point.x, point.y)
  })
  canvasCtx.fill()
}
canvasCtx.fillStyle = 'rgba(255,0,22,0.15)'
__fill(canvasCtx, mouthLandmarks)

関数ができたので、Vuexのactionから呼び出します。

draw ({ commit, state }, { canvasCtx, detection, color, transparent }) {
  const __mouthPoints = detection.landmarks.getMouth()
  const upperLip = [
    ...__mouthPoints.slice(0, 7),
    __mouthPoints[16],
    __mouthPoints[15],
    __mouthPoints[14],
    __mouthPoints[13],
    __mouthPoints[12]
  ]
  const lowerLip = [
    ...__mouthPoints.slice(6, 12),
    __mouthPoints[0],
    __mouthPoints[12],
    __mouthPoints[19],
    __mouthPoints[18],
    __mouthPoints[17],
    __mouthPoints[16]
  ]
  canvasCtx.fillStyle = `${color.slice(0, -1)}, ${transparent})`
  __fill(canvasCtx, upperLip)
  __fill(canvasCtx, lowerLip)
},

リップが点滅する

当初の実装と事象

対面カメラの映像にリップを塗るために以下を実装します。

  1. videoの内容をcanvasに表示

  2. 顔認識

  3. 唇のランドマーク取得

  4. リップ描画

  5. 1~4をsetIntervalで繰り返す

すると、前のintervalの4で書き込んだリップは、次のintervalの1で書き込まれたvideoに隠れてしまいます。

つまり、2と3の顔認識でラグればラグるほど、リップをvideoで上書きしてからもう一度リップを描画するまでが遅くなってしまい、ユーザーには、リップが点滅しているように見えてしまいます。

解決方法

唇のランドマークを取得したらその位置をキャッシュし、1の後すぐに描画する。

  1. videoの内容をcanvasに表示
  2. 顔認識結果がキャッシュされてたらリップ描画
  3. 顔認識
  4. 認識結果をキャッシュ
  5. 唇のランドマーク取得
  6. リップ描画
  7. 1~5をsetIntervalで繰り返す

video to canvasがiPhoneでインライン再生できない

canvasだけ表示したいのでvideoにhidden要素を指定して隠していましたが、safariではvideoを隠すと再生できないようになっています。

解決方法

#live-video {
  position: absolute;
  top: 10px; left: 10px;
  object-fit: fill;
  transform-origin: left top;
  transform: scale(.1);
}

canvasの左上にvideoも小さく表示するようにしました。transform: scale(.001)とかにしてしまえば見えないも同然です。

webRTCのアスペクト比がなかなか合わない

解決方法

getUserMediaのconstraintsで設定します。

const constraints = {
  aspectRatio: 0.75
}
const stream = await navigator.mediaDevices.getUserMedia({ video: constraints })

ちなみにこれでもiPhone safariではアスペクト比をいじれませんでした。

気合いで3:4のcanvasに合わせる関数を実装しました。

drawImage ({ commit, state }, { canvasDiv, canvasCtx, imagePath }) {
  const image = new Image()
  image.addEventListener('load', () => {
    let width, height, xOffset, yOffset
    if (image.width * 1.34 > image.height) {
      height = canvasDiv.height
      width = image.width * (canvasDiv.height / image.height)
      xOffset = -(width - canvasDiv.width) / 2
      yOffset = 0
    } else {
      width = canvasDiv.width
      height = image.height * (canvasDiv.width / image.width)
      yOffset = -(height - canvasDiv.width) / 2
      xOffset = 0
    }
    canvasCtx.drawImage(image, xOffset, yOffset, width, height)
  })
  image.src = imagePath
}

ちなみにこれでもダメでした。
方針を切り替え、スマホではカメラモードを使えないようにしてしまいます。
以下のような関数を作ってユーザーのデバイスを取得し、PC以外の端末ではカメラモードを使えないようにしてしまいました。

const getDevice = () => {
  const ua = navigator.userAgent
  if (
    (ua.indexOf('iPhone') > 0 || ua.indexOf('iPod') > 0 || ua.indexOf('Android') > 0) && ua.indexOf('Mobile') > 0) {
    return 'sp'
  } else if (ua.indexOf('iPad') > 0 || ua.indexOf('Android') > 0) {
    return 'tab'
  } else {
    return 'other'
  }
}

export default {
  getDevice,
}

iPhoneで画像アップロードすると回転する

iPhoneの画像はExifと呼ばれるメタデータを持っています。
ファイルサイズ、位置情報、撮影日時、回転情報などがこれに含まれます。
これを描画時に反映させないと意図しない方向に回転して表示されてしまいます。

解決方法

便利なライブラリを使って解決しました。

ライブラリインストール

npm install blueimp-load-image

インポート

import loadImage from 'blueimp-load-image'

実装例

onImageChange (file) {
  if (file !== undefined && file !== null) {
    if (file.name.lastIndexOf('.') <= 0) {
      return
    }
    loadImage.parseMetaData(file, (data) => {
      const options = {
        canvas: true
      }
      if (data.exif) {
        options.orientation = data.exif.get('Orientation')
      }
      loadImage(file, async (canvas) => {
        this.imageUrl = canvas.toDataURL('image/jpeg')
        // 顔認識と描画処理
      }, options)
    })
  } else {
    this.imageUrl = ''
  }
}

リップの製品情報を取得

こんな感じでデータを作っていきます。
公式サイトを訪問し、カラーピッカーで一つ一つリップの色を取得する地獄のような作業でした。
地球上の全ブランドを網羅するぐらいの気概で開発を始めましたが、ここで心折れました。

brands: [
  {
    name: 'THREE',
    items: [
      {
        name: 'THREE Daringly Distinct Lipstick',
        colors: [
          {id: '01', code: 'rgb(198,29,67)'},
          {id: '02', code: 'rgb(189,38,79)'},
          {id: '03', code: 'rgb(208,62,80)'},
          {id: '04', code: 'rgb(221,72,110)'},
          {id: '05', code: 'rgb(218,83,126)'},
          {id: '06', code: 'rgb(232,114,136)'},
          {id: '07', code: 'rgb(233,79,111)'},
          {id: '08', code: 'rgb(232,57,74)'},
          {id: '09', code: 'rgb(234,110,146)'},
        ]
      },
      {
        name: 'THREE Daringly Demure Lipstick',
        colors: [
          {id: '01', code: 'rgb(216,49,105)'},
          {id: '02', code: 'rgb(239,53,66)'},
          {id: '03', code: 'rgb(241,81,105)'},
          {id: '04', code: 'rgb(221,80,97)'},
          {id: '05', code: 'rgb(210,97,97)'},
          {id: '06', code: 'rgb(183,74,111)'},
          {id: '07', code: 'rgb(128,17,33)'},
          {id: '08', code: 'rgb(159,24,48)'},
          {id: '09', code: 'rgb(95,19,24)'},
        ]
      }
    ]
  },
]

終わり

リップの情報を取ってくるのが辛すぎたので、このプロダクトのことは一旦忘れることにしました。
それでも技術的に以下の知見が得られたので作ってよかったです。

  • webRTC
  • canvas
  • 顔認識
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Firebase チュートリアル FriendlyEats-vue

FriendlyEats-vue

1. FriendlyEats-vue について

FriendlyEats-vueは、Vueを使ったFirebase / Cloud Firestoreのチュートリアル用のアプリです。Cloud Firestoreを学習するために最小限のプログラムをするだけでCloud Firestoreを使ったアプリケーションを作ることができます。

sample

このチュートリアルでは以下のことを学習します。
- WebアプリケーションからCloud Firestoreへの読み書きをする
- リアルタイムにCloud Firestoreのデータの変更を受け取る
- Firebaseのユーザ認証を使ったり、Security Rulesを使ってCloud Firestoreのデータを安全に読み書きする
- Cloud Firestoreの複雑なクエリーを書く

このチュートリアルを始めるに当たって、必要な開発環境は以下となります。

  • Gitクライアント。GitHubのアカウントもあれば用意してください
  • Node.jsとnpm - Nodeはversion 8をお薦めします
  • IDEやテキストエディタ。たとえば Emacs, vim, WebStorm, Atom, VS Code, Sublime などからお好きなものを選んでください

2. Firebase projectの作成と設定

Firebase projectを作成する

  1. Firebaseのコンソール上で「プロジェクトを追加」をクリックします
  2. プロジェクト名前を入力します。「FriendlyEats」と入力してください
  3. 入力したプロジェクト名の下にプロジェクトIDが表示されます(変更可能です) 作成プロジェクトIDは忘れないように!
  4. [続行]をクリックします
  5. Google アナリティクス画面で「今は必要ない」を選択します
  6. [プロジェクトを作成]をクリックします
  7. 「新しいプロジェクトの準備ができました」が表示されます。[続行]をクリックします

重要: 作成された Firebaseのプロジェクトは「FriendlyEats」という名前ですが、Firebaseは自動的に「friendlyeats-1234」のような固有のプロジェクトIDを割り当てます。この固有のIDは、あなたのプロジェクトを識別するのに必要です(CLIなどで)。「FriendlyEats」は単にプロジェクトの名前です。

これから作成するアプリケーションでは、ウェブ上で使えるFirebaseのサービスのうちいくつかを利用します。

  • Firebase Authentication - ユーザーを簡単に管理/識別します
  • Cloud Firestore - クラウド上に構造化されたデータを保存し、データが更新された時は即座に通知します
  • Firebase Hosting - 静的なコンテンツをホスティングします

以下では、Firebaseコンソールを用いた「Firebase Auth」および「Cloud Firestore」の設定方法について、順を追って説明します。

Anonymous Auth (匿名認証)を有効にする

認証はこのチュートリアルの焦点ではありませんが、何らかの形式の認証を使用することは重要です。
このアプリでは、匿名ログインを使用します。つまりユーザーは明示的な操作をすることなくログインします。

そのためには、匿名ログインを有効にする必要がありまず。

  1. ブラウザで、Firebaseのコンソールを表示します
  2. 左のナビゲーションメニュー「開発」の「Authentication」をクリックします
  3. 「ログイン方法」タブをクリックします
  4. 「ログインプロバイダ」の「匿名」をクリックし「有効」にしてください
  5. 最後に[保存]をクリックします

fee6c3ebdf904459.png

FriendlyEats

これでユーザーがWebアプリにアクセスするときに、匿名でログインできるようになりました。詳細は、匿名認証のドキュメントをお読みください。

Cloud Firestoreを有効にする

このアプリは、レストランの情報や評価を保存、更新情報を受け取るために、Cloud Firestore(データーベース)を使います。

そのためには、Cloud Firestoreを有効にする必要があります。

  1. ブラウザで、Firebaseコンソールを表示します
  2. 左のナビゲーションメニュー「開発」の「Database」をクリックします
  3. Cloud Firestoreペインで「データベースの作成」をクリックします 8c5f57293d48652.png
  4. オプションの「テストモードで開始」を選択し、セキュリティルールに関する免責事項を読んだ後、[次へ]をクリックします
  5. ロケーションを選択し(デフォルトのままでも構いませんが、後から変更することはできません)、[完了]をクリックします

テストモードでは、開発中にCloud Firestoreへ書き込みが自由にできるようになります。セキュリティを強化は、このチュートリアルの後半でおこないます。

620b95f93bdb154a.png

3. サンプルのソースコード取得とインストール

ソースコードを取得する

以下のコマンドを使って GitHub レポジトリをクローンします

git clone https://github.com/isamu/FriendlyEats-vue
  • 自分の変更をGitHubで管理したい場合には、Forkしてcloneしてください

サンプルコードは?FriendlyEats-vueディレクトリにCloneする必要があります。
以後、このディレクトリ内でコマンドラインを実行してください。

cd FriendlyEats-vue

npmをインストールする

npmのパッケージをインストールします。

npm install

Firebaseの設定を取得し firebase.js を書き換える

Firebaseのコンソールから設定を取得し、src/firebase/firebase.js にコピーします。

  • Firebaseのコンソール を開いて「FriendlyEats」を選択します
  • プロジェクトのダッシュボードの「Get started by adding Firebase to your app」から「Web」 を選択します
  • 「Register app」で、「App nickname」に「FriendlyEats」と入力し、「Also set up Firebase Hosting」にチェックを入れ、「Register app」をクリックします
  • 再度、Firebaseのコンソール を開いて「FriendlyEats」を選択します
  • 左側のメニューから「ProjectOverview」の左横の「設定アイコン」をクリックし「Project settings」を選択します
  • アプリの設定画面(Settings)の 全般タブ > Firebase SDK snippet > 構成 を選択します
  • const firebaseConfig で始まる部分をコピーし、src/firebase/firebase.js 内の相当する部分を置き換えます

スターターアプリをインポートする

IDE(WebStorm、Atom、Sublime、Visual Studio Code ...)を使用している場合、?FriendlyEats-vueディレクトリを開くかインポートします。このディレクトリには、レストラン情報とオススメ情報を表示するアプリの未完成なモックコードが含まれています。チュートリアルを通してこのアプリを実装していくので、このモックコードを編集できる必要があります。

4. Firebase CLI (コマンドラインツール)のインストール

Firebaseコマンドラインインターフェイス(CLI)を使用すると、Webアプリをローカルで開発したり、Firebase Hostingにデプロイすることができます。

Note: CLIをインストールするには、通常NodeJSに付属しているnpmをインストールする必要があります。

1 . 次のnpmコマンドを実行して、CLIをインストールします。

npm -g install firebase-tools

動作しませんか? npmのpermissionを変更する必要がある場合があります。

2 . 次のコマンドを実行して、CLIが正しくインストールされたことを確認します。

firebase --version

Firebase CLIのバージョンがv6.2.0以降であることを確認してください。

3 . 次のコマンドを実行して、Firebase CLIを認証します。

firebase login

Firebase Hostingのアプリの設定をアプリのローカルディレクトリとファイルから取得するように、ウェブアプリテンプレートを設定しました。ただし、これを行うには、アプリをFirebaseプロジェクトに関連付ける必要があります。

4 . コマンドラインが、先ほどcloneしたディレクトリーになっているか確認してください(通常FriendlyEats-vueディレクトリー。pwdで現在のディレクトリーを確認できます)

5 . 次のコマンドを実行して、アプリをFirebaseプロジェクトに関連付けます。

firebase use --add

6 . プロンプトが表示されたら、本プロジェクトのプロジェクトIDを選択し、Firebaseプロジェクトにエイリアスを指定します。
エイリアスは、複数の環境(本番、ステージングなど)を切り替える場合に役立ちます。ただし、このチュートリアルでは、defaultというエイリアス名を入力します(スペルを間違えると後にインデックスをデプロイする操作等でエラーが発生するので注意してください)。

7 . コマンドラインの残りの指示に従ってください。

5. Vueをローカルで起動する

アプリで実際に作業を開始する準備ができました!アプリをローカルで実行しましょう!

1 . 次のコマンドをローカルのCLIで実行します:

npm run serve

2 . 成功すると次の文を含むメッセージが表示されます

  - Local:   http://localhost:8080/ 

Vueサーバがローカルで起動しています。 ブラウザ http://localhost:8080 を開くとサンプルアプリを見ることができます。 Vueを起動すると自動的に開く場合もあります。8080という数字は少し別の番号になっている場合もあります。

3 . ブラウザで http://localhost:8080 を見る

クラウド上のFirebaseプロジェクトに接続されているFriendlyEatsアプリが表示されます(初回起動時はしばらく時間がかかる場合があります)。

アプリは自動的にクラウド上のFirebaseプロジェクトに接続し、匿名ユーザーとしてサインインしました。

スクリーンショット 2019-08-03 4.28.16.png

6. Cloud Firestoreへデータの書き込み

このセクションでは、Cloud Firestoreにデータを書き込みます。Firebaseコンソール上で手動でデータ入力を行うこともできますが、Cloud Firestoreの基本的な書き込みを学習する為に、アプリ自体でデータ生成/入力を行います。

データモデル

Firestoreデータは、コレクション、ドキュメント、フィールド、およびサブコレクションで構成されています。各レストラン情報をドキュメントとして、restaurantsと呼ばれる最上位のコレクションに保存します。

そして、各レストランのレビューをratingsと名付けたサブコレクションに保存します。

Tip: Firestoreデータモデルの詳細については、ドキュメントのドキュメントとコレクションをご覧ください。

Firestoreにレストラン情報を追加する

このアプリの主なモデルオブジェクトはrestaurantです。restaurantsコレクションにレストランのドキュメントを追加するコードを書きましょう。

  1. cloneしたソースコードの src/components/FriendlyEats.Data.js ファイルを開きます
  2. addRestaurant 関数を探します
  3. 関数全体を以下のコードに置き換えます

FriendlyEats.Data.js

export const addRestaurant = (data) => {
  const collection = firebase.firestore().collection('restaurants');
  return collection.add(data);
};

上記のコードにより、restaurantsコレクションに新しいドキュメント(データ)が追加されます。ドキュメントのデータはJavaScriptオブジェクトです。

この関数は、次のような処理をします。

  1. レストランのデータを引数として取得します
  2. Cloud Firestoreのrestaurantsコレクションへの参照を取得します
  3. 引数で受け取ったデータは、レストランオブジェクトとしてランダムに自動生成し、ドキュメントに追加します

(* 実際にどのようにデータが生成されるか興味がある人は src/FriendlyEats/FriendlyEats.Mock.jsaddMockRestaurantsgetRandomRestaurantの実装を見てください。)

restaurants情報を追加しよう!

  1. ブラウザのFriendlyEatsアプリに戻り、画面を更新します
  2. 「IMPORT DATA」をクリックします

まだ画面には何も表示されませんが、Cloud Firestoreにはデータが登録されているはずです。

実際にみてみましょう。

Firebaseコンソールの「Cloud Firestore」タブに移動すると、restaurantsコレクションに新しいドキュメントが表示されます。

f06898b9d6dd4881.png

おめでとうございます!!WebアプリからCloud Firestoreにデータを書き込みが成功しました!!

次のセクションでは、Cloud Firestoreからデータを取得してアプリに表示する方法を学習します。

7. Cloud Firestore のデータを表示

このセクションでは、Cloud Firestoreからデータを取得してアプリに表示する方法を学習します。 2つの重要な手順は、クエリの作成とスナップショットリスナーの追加です。このリスナーには、クエリに一致するすべての既存データが通知され、更新をリアルタイムで受信します。

最初に、レストランのデフォルトのフィルタリングされていないリストを提供するクエリを作成しましょう。

  1. src/components/FriendlyEats.Data.js ファイルを開きます
  2. getAllRestaurants 関数を探します
  3. 関数全体を以下のコードに置き換えます

FriendlyEats.Data.js

export const getAllRestaurants = () => {
  const query = firebase.firestore()
        .collection('restaurants')
        .orderBy('avgRating', 'desc')
        .limit(50);

  return query;
};

上記のコードでは、restaurantsという名のトップレベルコレクションから最大50件のレストランを取得するクエリを作成しています。これらは評価の平均順(現在はすべてゼロ)に並べられています。このクエリを定義後、データの読み込みとレンダリングを行うgetDocumentsInQuery関数にこのクエリを渡します。

これを行うには、スナップショットリスナーを追加します。

  • src/components/FriendlyEats.Data.js を開きます
  • getDocumentsInQuery 関数を探します
  • 関数全体を以下のコードに置き換えます

FriendlyEats.Data.js

export const getDocumentsInQuery = (query, renderer) => {
  return query.onSnapshot((snapshot) => {
    if (!snapshot.size) return renderer.empty();

    snapshot.docChanges().forEach((change) => {
      if (change.type === 'removed') {
        renderer.remove(change.doc)
      } else {
        renderer.display(change.doc)
      }
    });
  });
};

上記のコードでは、クエリの結果に変更があるたびにquery.onSnapshotをコールバックで呼び出します。

  • 最初のコールバックは、クエリの結果、全体のデータをsnapshotとして渡します。これは、Cloud Firestoreのrestaurantsコレクション全体(50件)を意味します。そしてchangeには、全ての個々のドキュメントが渡され、それをrenderer.display関数に渡します。
  • ドキュメントが削除された時には、change.typeremovedとなります。したがって、この場合、UIからレストランを削除する関数を呼び出します。

両方のメソッドを実装したので、アプリを更新し、Firebaseコンソールで前に表示したレストラン情報がWebアプリに表示されていることを確認します。このセクションを正常に完了した場合、WebアプリはCloud Firestoreでデータを読み書きできています。

レストランのリストが変更されると、このリスナーは自動的にデータを更新します。
Firebaseコンソールに移動して、レストランを手動で削除するか、名前を変更してみてください。サイト上のデータも更新されます。

Note: Query.get()メソッドを使用することにより、更新通知を常時リアルタイムに受け取るのではなく、Cloud Firestoreからドキュメントを一度だけ取得することもできます。

sample.jpg

8. データを取得する

ここまでは、onSnapshotを使用して更新をリアルタイムで取得する方法を実装しました。
つぎは、アプリ内の特定のレストランをクリックした時にトリガーされる機能を実装しましょう。

  1. src/components/FriendlyEats.Data.js を開きます
  2. getRestaurant関数を探します
  3. 関数全体を以下のコードに置き換えます

FriendlyEats.Data.js

export const getRestaurant = (id) => {
  return firebase.firestore().collection('restaurants').doc(id).get();
};

このメソッドを実装すると、各レストランのページを表示できるようになります。リスト内のレストランをクリックするだけで、レストランの詳細ページが表示されます。

スクリーンショット 2019-08-03 4.32.01.png

現時点では評価を追加することはできませんが、この機能はチュートリアルの後半で実装します。

9. データのソートと絞り込み

今のところ、アプリにはレストランのリストが表示されていますが、ユーザーがニーズに基づいてフィルタリングする方法はありません。このセクションでは、Cloud Firestoreの高度なクエリを使用してフィルタリングを有効にします。

すべての「点心(Dim Sum)」レストランを取得する簡単なクエリの例を次に示します。

var filteredQuery = query.where('category', '==', 'Dim Sum')

その名前が示すように、where() メソッドは、条件に一致するフィールドを持つコレクション内のドキュメントを取得します。この場合、カテゴリが「点心(Dim Sum)」のレストランのみを取得しています。

このアプリでは、ユーザーは複数のフィルターをチェーンして、「サンフランシスコのピザ」や「人気のあるロサンゼルスのシーフード」などの特定のクエリを作成できます。

それでは、ユーザーが選択した複数の条件に基づいてレストランをフィルタリングするクエリを作成するメソッドを作成してみましょう。

  1. src/components/FriendlyEats.Data.js を開きます
  2. getFilteredRestaurantsを探します
  3. 関数全体を以下のコードに置き換えます

FriendlyEats.Data.js

export const getFilteredRestaurants = (filters) => {
  let query = firebase.firestore().collection('restaurants');

  if (filters.category !== 'Any') {
    query = query.where('category', '==', filters.category);
  }

  if (filters.city !== 'Any') {
    query = query.where('city', '==', filters.city);
  }

  if (filters.price !== 'Any') {
    query = query.where('price', '==', filters.price.length);
  }

  if (filters.sort === 'Rating') {
    query = query.orderBy('avgRating', 'desc');
  } else if (filters.sort === 'Reviews') {
    query = query.orderBy('numRatings', 'desc');
  }
  return query;
};

上記のコードは、複数のwhereフィルターと1つのorderByを追加して、ユーザー入力に基づいて複合クエリを作成します。このクエリは、ユーザーの要件に一致するレストランのみを返します。

ここで、ブラウザでFriendlyEatsアプリを更新し、価格や都市などのカテゴリでフィルタリングできることを確認しようとしても、まだ完全には動きません。検索結果は「Your Cloud Firestore has no documents in /restaurants/」と表示されます。また、ブラウザのJavaScriptコンソールに次のようなエラーが表示される場合があります。

The query requires an index. You can create it here: https://console.firebase.google.com/project/.../database/firestore/indexes?create_index=...

このエラーが発生する理由は、Cloud Firestoreでほとんどの複合クエリにインデックスが必要なのですが、それをまだ用意していないためです。クエリの際にインデックスを必要とすることで、規模が拡大してもCloud Firestoreを高速に保ちます。

次のセクションでは、このアプリケーションに必要なインデックスを作成してデプロイします。

10. Cloud Firestoreにindexを追加

アプリ内のすべてのパスを探索し、各インデックス作成リンクをたどる必要がない場合は、Firebase CLIを使用して多数のインデックスを一度に簡単に展開できます。

ダウンロードしたソースコードのルートディレクトリに、firestore.indexes.jsonファイルがあります。このファイルには、フィルターに必要なすべてのインデックスが記述されています。

firestore.indexes.json

{
 "indexes": [
   {
     "collectionGroup": "restaurants",
     "queryScope": "COLLECTION",
     "fields": [
       { "fieldPath": "city", "order": "ASCENDING" },
       { "fieldPath": "avgRating", "order": "DESCENDING" }
     ]
   },

   ...

 ]
}

次のコマンドでこれらのインデックスをデプロイします。

firebase deploy --only firestore:indexes

数分後、インデックスが有効になり、エラーメッセージが消えます。

Tip: Cloud Firestoreのインデックスの詳細については、ドキュメントをご覧ください。

11. トランザクションを使ってデータの書き込み

このセクションでは、ユーザーがレストランにレビューを書き込みする機能を実装します。

今までのところ、書き込みはすべてアトミックで比較的単純です。もし書き込みエラーが発生した場合でも、おおむね単にユーザーに再試行を促すか、でなければアプリ自身が自動的に再試行するでしょう。

しかし、このアプリにはレストランの評価を追加したいユーザーが多数いるため、読み取りと書き込みが複数回あった場合、それらの調整する必要があります。つまり、最初にレビューが作成されなければならず、次いでレストランの評価数 count と平均評価 average rating を更新する必要があります。そしてこれらの操作のうち、どれか1つが失敗し、他が成功した場合、データベースのある部分のデータが別の部分のデータと一致しない、矛盾した状態になります。

幸いなことに、Cloud Firestoreには、単一のアトミック操作で複数の読み取りと書き込みを可能にするトランザクション機能が用意されており、これによりデータの一貫性を維持できます。

  1. src/components/FriendlyEats.Data.js を開く
  2. addRating 関数を探す
  3. 関数全体を以下のコードに置き換えます

FriendlyEats.Data.js

export const addRating = (restaurantID, rating) => {
  const collection = firebase.firestore().collection('restaurants');
  const document = collection.doc(restaurantID);
  const newRatingDocument = document.collection('ratings').doc();

  return firebase.firestore().runTransaction(function(transaction) {
    return transaction.get(document).then(function(doc) {
      const data = doc.data();

      const newAverage =
            (data.numRatings * data.avgRating + rating.rating) /
            (data.numRatings + 1);

      transaction.update(document, {
        numRatings: data.numRatings + 1,
        avgRating: newAverage
      });
      return transaction.set(newRatingDocument, rating);
    });
  });
};

上記のブロックでは、restaurantsドキュメントのaverageRatingratingCountの数値を更新するトランザクションを呼び出します。同時に、ratingsサブコレクションに新しいratingを追加します。

注:評価の追加にトランザクションを使うことは、このチュートリアルのケースでは良い使用例です。ただし、実運用アプリでは、ユーザーによる不正操作を避けるために、平均評価の算出は信頼できるサーバーで行う必要があります。これを行う良い方法は、クライアントから直接評価ドキュメントを作成し、Cloud Functionsを利用して新しいレストランの平均評価を更新することです。

警告:サーバーでトランザクションが失敗すると、コールバックも繰り返し再実行されます。アプリの状態を変更するロジックをトランザクションコールバック内に配置しないでください。

12. データを守る

このチュートリアルの最初に、アプリのセキュリティルールをテストモードに設定し、自由に読み書きできるようにしました。
実際のアプリケーションでは、望ましくないデータの読み込みや変更を防ぐために、よりきめ細かいルールを設定する必要があります。

  1. Firebase console を開き、開発 > Database を選択します
  2. Cloud Firestore > ルール タブをクリックします
  3. rules_version = '2'; より下のコードを以下のルールに置き換えて「公開」をクリックします

firestore.rules

service cloud.firestore {
  match /databases/{database}/documents {

        // Restaurants:
        //   - Authenticated user can read
        //   - Authenticated user can create/update (for demo)
        //   - Validate updates
        //   - Deletes are not allowed
    match /restaurants/{restaurantId} {
      allow read, create: if request.auth != null;
      allow update: if request.auth != null
                    && request.resource.data.name == resource.data.name
      allow delete: if false;

      // Ratings:
      //   - Authenticated user can read
      //   - Authenticated user can create if userId matches
      //   - Deletes and updates are not allowed
      match /ratings/{ratingId} {
        allow read: if request.auth != null;
        allow create: if request.auth != null
                      && request.resource.data.userId == request.auth.uid;
        allow update, delete: if false;

        }
    }
  }
}

これらのルールはアクセスを制限して、クライアントが安全な変更のみ行えることを保証します。例えば:

  • レストランのドキュメントの更新では、評価のみが変更でき、名前やその他の不変なデータは変更できません
  • ユーザーIDがサインインしているユーザーと一致する場合にのみ評価を作成できます。これにより、なりすましが防止できます

FirebaseのConsoleを使うかわりに、Firebase CLIを使用してルールをFirebaseプロジェクトに展開できます。作業ディレクトリのfirestore.rulesファイルには、上記のルールが既に含まれています。これらのルールをローカル環境からFirebaseにデプロイするには、次のコマンドを実行します。

firebase deploy --only firestore:rules

重要:セキュリティルールの詳細については、セキュリティルールのドキュメントをご覧ください。

13. デプロイ

まず、Vueをビルドします。

npm run build

build/以下に静的なファイルが生成されます。

つぎに Cloud Firebase へデプロイします。

firebase deploy --only hosting

以下のように表示されるとデプロイ成功です。

✔  Deploy complete!

Project Console: https://console.firebase.google.com/project/friendlyeats-vue/overview
Hosting URL: https://friendlyeats-vue.firebaseapp.com

Hosting URLをブラウザで見てみましょう。作成したアプリケーションが見えます!

14. まとめ

このチュートリアルでは、Cloud Firestoreで基本的な、そして高度な読み取りと書き込みを行う方法と、セキュリティルールでデータアクセスを保護する方法を学びました。

Cloud Firestore の詳細については、次のリソースをご覧ください:

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

jestでvueファイルのカバレッジが収集されない問題への対処

概要

最近になって Codecov というサービスを知ってテストのカバレッジに気を使うようになったのですが、Jestを使って*.vueのカバレッジを取ろうとしても無視されるような動作に悩まされていました。
(おそらくはv25など特定のバージョンに起因する問題)

2020年2月時点の話で、直近のアップデートで改善されると思われるものの
メモしておきます。

image.png
図: カバレッジにjsファイルは表示されても、vueファイルがカウントされない

TL;DR

vue-jestのIssue#217のコメントを参考に、以下を行いyarn test を再実行したところ、カバレッジが収集されることを確認しました。

(1). パッケージのインストール

yarn add -D @vue/cli-plugin-unit-jest @vue/cli-service

(2). package.json(scriptsセクション)の編集
(自分のプロジェクトはLaravelが生成したpackage.jsonで、VueCLIを使用していなかったため)

# 変更前
"test": "jest",

# 変更後
"test": "vue-cli-service test:unit",

(3). yarn test を再実行して確認
今度はvueファイルのカバレッジが取れていることを確認できます。

image.png

参考情報

各種バージョン

jest: 25.1.0
vue-jest: 3.0.5

この事象発生時のJestの設定

    "jest": {
        "globals": {
            "Vue": true
        },
        "moduleFileExtensions": [
            "js",
            "json",
            "vue"
        ],
        "collectCoverage": true,
        "collectCoverageFrom": [
            "<rootDir>/resources/js/**/*.{js,vue}",
            "!**/node_modules/**",
            "!**/vendor/**"
        ],
        "setupFiles": [
            "./tests/js/setup.js"
        ],
        "transform": {
            "^.+\\.js$": "babel-jest",
            "^.+\\.vue$": "vue-jest"
        },
        "moduleNameMapper": {
            "^@/(.*)$": "<rootDir>/resources/js/$1"
        }
    }
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Vue.js】HTMLかけない人でも静的ファイルを読み込んでいい感じの記事を書けるようにする

HTMLかけない人でも静的ファイルを読み込んでいい感じの記事を書けるようにする

動機

  • HTMLかけない人でもいい感じの記事やテキストページを書けるようにしたい
  • HTMLかける人でもテキストページくらいは労力減らして書けるようにしたい
  • 利用者自身で好きに編集できるようにしたい
  • 内容をDBに保存したりとかAPI連携するとか考えたくない
  • 管理画面ちゃんと作るのめんどくさい

実現したもの

  • Markdown使う
  • .mdの静的ファイルを読み込むようにしたのでファイル上書きすれば更新可能

デモ

mdsampleGif - Google Chrome 2020-02-24 22-18-03.gif

インポートしてるmdファイルの中身

  • markdown.md
# 見出し1
## 見出し2
 `くつしたねこ`, `ハチワレねこ`, または `ジト目ねこ`
+ 哺乳類
  - ねこ
    * メインクーン
    + ノルウェージャンフォレストキャット
    - サバンナキャット
+ Very cute!

好きな柄順

1. 長さの違う靴下
2. 長さがだいたい同じ靴下
3. 全部


:smile:
> 猫
>> ねこ
> > > ?

\```
ねこ
ねこ
ねこ
\```


| 種類 | 詳細 |
| ------| -----------|
| メインクーン | かわいい |
| ノルウェージャンフォレストキャット | かわいい|
| サバンナキャット | かわいい |

## Links

[wiki:ネコ](https://ja.wikipedia.org/wiki/%E3%83%8D%E3%82%B3)

[ホバーで説明が出るリンク](https://ja.wikipedia.org/wiki/%E3%83%8D%E3%82%B3 "ねこかわいい")



![ねこ](./img/cat1.jpg)


 19^th^
- H~2~O

++Inserted text++

==Marked text==


Footnotes

Footnote 1 link[^first].

Footnote 2 link[^second].

Inline footnote^[Text of inline footnote] definition.

Duplicated footnote reference[^second].

### Abbreviations

This is HTML abbreviation example.

It converts "HTML", but keep intact partial entries like "xxxHTMLyyy" and so on.

* [HTML]: Hyper Text Markup Language

画像はpublicのフォルダ配下に配置する。
今回はpublicの下にimgフォルダを作って画像を置いているので
http://localhost:8080/img/cat1.jpg
で画像にアクセスできる

実装

使ったもの

Vue.js
vue-markdown
 VuiCLI3を使ってるので
 npm install --save vue-markdown @vue/cli でインストールした
axios

階層

MarkdownSmaple
  ┠ public
  ┃  ┠ markdown.md  --importするmarkdownのファイル
  ┃  ┠ img
  ┃  ┗  ┗ cat1.jpg  --markdown内で使う画像ファイル
  ┠ src
  ┃  ┠ main.js
  ┃  ┠ assets
  ┃  ┠ components
  ┃  ┃ ┠ Markdown.vue
  ┃  ┃ ┠ md.css   --markdown用のcss
  ┗  ┗ ┗ md_table.css  --markdown用(table)のcss

コード

  • main.js

axiosを使えるようにする

import Vue from 'vue'
import App from './App.vue'
import axios from 'axios' //追加

Vue.config.productionTip = false
Vue.prototype.$axios = axios //追加

new Vue({
  render: h => h(App)
}).$mount('#app')

  • Markdown.vue
<template>
  <div>
    <v-row id="md">
      <v-col>
        <vue-markdown :source="source"></vue-markdown>
      </v-col>
    </v-row>
  </div>
</template>

<script>

import VueMarkdown from "vue-markdown";
export default {
  components: {
    VueMarkdown
  },
  data() {
    return { source: "" };
  },
  mounted: function() {
   /* publicのフォルダに置いたmdファイルを取得する
      public配下はbuildするとroot直下として扱われる
   assetsフォルダの下に置くと読み込まれないので注意 */
    this.$axios
      .get("./markdown.md")
      .then(response => (this.source = response.data));
  }
};
</script>
<style>
/* cssファイルをインポート */
@import "./md.css";
@import "./md_table.css";
#md {
  position: relative;
  left: 5%;
}
</style>
  • md.css

ここのcssを一部改修した
cssを変更すればデザインも変わるのでいい感じにしよう

@import url(http://fonts.googleapis.com/css?family=Ubuntu:bold);
@import url(http://fonts.googleapis.com/css?family=Vollkorn);


html { 
  font-size: 100%; 
  overflow-y: scroll; 
  -webkit-text-size-adjust: 100%; 
      -ms-text-size-adjust: 100%; 
}

body {
  max-width:42em;
  padding:1em;
  margin:auto;
  color:#444;
  line-height:1.5em;
  font-family:Vollkorn, Georgia, Palatino, 'Palatino Linotype', Times, 'Times New Roman', serif;
  font-size:12px;
  background:#fefefe;
}

a { 
  color: #337ab7; 
  text-decoration:none;
}

a:visited { color: #0b0080; }
a:hover   { color: #06e; }
a:active  { color:#faa700; }
a:focus   { outline: thin dotted; }
a:hover, a:active { outline: 0; }

::-moz-selection{ background: rgba(255,255,0,0.3); color: #000}
::selection{      background: rgba(255,255,0,0.3); color: #000}

a::-moz-selection{ background: rgba(255,255,0,0.3); color: #0645ad}
a::selection{      background: rgba(255,255,0,0.3); color: #0645ad}

p {
  margin: 1em 0;
}

img {
  max-width: 100%;
}

h1,h2,h3,h4,h5,h6 {
  font-family: 'Ubuntu';
  font-weight:normal;
  color:#111;
  line-height:1em;
}
h4, h5, h6 { font-weight: bold; }
h1 { font-size:2.5em; }
h2 { font-size:2em; }
h3 { font-size:1.5em; }
h4 { font-size:1.2em; }
h5 { font-size:1em; }
h6 { font-size:0.9em; }

blockquote {
  padding-left: 3em;
  margin: 0;
  color: #666666;
  border-left: 0.5em #EEE solid;
}

hr {
  padding: 0; 
  margin: 1em 0; 
  display: block; 
  height: 2px; 
  border: 0; 
  border-top: 1px solid #aaa;
  border-bottom: 1px solid #eee; 
}

pre, kbd, samp { 
  color: #000; 
  font-family: monospace, monospace; 
  _font-family: 'courier new', monospace; 
  font-size: 0.98em; 

}
 code{ 
    padding: 2px 4px;
    font-size: 90%; 
    color: #c7254e; 
    background-color: #f9f2f4;
    border-radius: 4px;
    font-family: monospace, monospace; 
    _font-family: 'courier new', monospace; 
    font-size: 0.98em; 

  }

pre { 
  white-space: pre; 
  white-space: pre-wrap; 
  word-wrap: break-word; 
  color: #333;
  background-color: #f5f5f5;
  border: 1px solid #ccc;
  border-radius: 4px;
}

pre code {
    padding: 0;
    font-size: inherit;
    color: inherit;
    white-space: pre-wrap;
    background-color: transparent;
    border-radius: 0;
}

b, strong { 
  font-weight: bold; 
}

dfn { font-style: italic; }

ins { color: #000;  text-decoration: underline; }

mark { background: #ff0; color: #000; font-style: italic; font-weight: bold; }

sub, sup { font-size: 75%; line-height: 0; position: relative; vertical-align: baseline; }
sup { top: -0.5em; }
sub { bottom: -0.25em; }

ul, ol { margin: 1em 0; padding: 0 0 0 2em; }
li p:last-child { margin:0 }
dd { margin: 0 0 0 2em; }

img { border: 0; -ms-interpolation-mode: bicubic; vertical-align: middle; }



@media only screen and (min-width: 480px) {
  body {
    max-width: 100%;
    font-size: 14px;
  }
}

@media only screen and (min-width: 768px) {
  body {
    max-width:42em;
    font-size: 16px;
  }
}

@media print {
  * { background: transparent !important; color: black !important; filter:none !important; -ms-filter: none !important; }
  body{font-size:12pt; max-width:100%;}
  a, a:visited { text-decoration: underline; }
  hr { height: 1px; border:0; border-bottom:1px solid black; }
  a[href]:after { content: " (" attr(href) ")"; }
  abbr[title]:after { content: " (" attr(title) ")"; }
  .ir a:after, a[href^="javascript:"]:after, a[href^="#"]:after { content: ""; }
  pre, blockquote { border: 1px solid #999; padding-right: 1em; page-break-inside: avoid; }
  tr, img { page-break-inside: avoid; }
  img { max-width: 100% !important; }
  @page { margin: 0.5cm; }
  p, h2, h3 { orphans: 3; widows: 3; }
  h2, h3 { page-break-after: avoid; }
}
  • md_table.css

長かったので外に出しただけ

table {
    border-spacing: 0;
    border-collapse: collapse;
  }

  td, th {
    padding: 0
  }
  thead {
    display: table-header-group
  }
  .table {
    width: 100%;
    max-width: 100%;
    margin-bottom: 20px
  }

  .table>tbody>tr>td, .table>tbody>tr>th, .table>tfoot>tr>td, .table>tfoot>tr>th, .table>thead>tr>td, .table>thead>tr>th {
    padding: 8px;
    line-height: 1.42857143;
    vertical-align: top;
    border-top: 1px solid #ddd
  }

  .table>thead>tr>th {
    vertical-align: bottom;
    border-bottom: 2px solid #ddd
  }

  .table>caption+thead>tr:first-child>td, .table>caption+thead>tr:first-child>th, .table>colgroup+thead>tr:first-child>td, .table>colgroup+thead>tr:first-child>th, .table>thead:first-child>tr:first-child>td, .table>thead:first-child>tr:first-child>th {
    border-top: 0
  }

  .table>tbody+tbody {
    border-top: 2px solid #ddd
  }

  .table .table {
    background-color: #fff
  }

  .table-condensed>tbody>tr>td, .table-condensed>tbody>tr>th, .table-condensed>tfoot>tr>td, .table-condensed>tfoot>tr>th, .table-condensed>thead>tr>td, .table-condensed>thead>tr>th {
    padding: 5px
  }

  .table-bordered {
    border: 1px solid #ddd
  }

  .table-bordered>tbody>tr>td, .table-bordered>tbody>tr>th, .table-bordered>tfoot>tr>td, .table-bordered>tfoot>tr>th, .table-bordered>thead>tr>td, .table-bordered>thead>tr>th {
    border: 1px solid #ddd
  }

  .table-bordered>thead>tr>td, .table-bordered>thead>tr>th {
    border-bottom-width: 2px
  }

  .table-striped>tbody>tr:nth-of-type(odd) {
    background-color: #f9f9f9
  }

  .table-hover>tbody>tr:hover {
    background-color: #f5f5f5
  }

  table col[class*=col-] {
    position: static;
    display: table-column;
    float: none
  }

  table td[class*=col-], table th[class*=col-] {
    position: static;
    display: table-cell;
    float: none
  }

  .table>tbody>tr.active>td, .table>tbody>tr.active>th, .table>tbody>tr>td.active, .table>tbody>tr>th.active, .table>tfoot>tr.active>td, .table>tfoot>tr.active>th, .table>tfoot>tr>td.active, .table>tfoot>tr>th.active, .table>thead>tr.active>td, .table>thead>tr.active>th, .table>thead>tr>td.active, .table>thead>tr>th.active {
    background-color: #f5f5f5
  }

  .table-hover>tbody>tr.active:hover>td, .table-hover>tbody>tr.active:hover>th, .table-hover>tbody>tr:hover>.active, .table-hover>tbody>tr>td.active:hover, .table-hover>tbody>tr>th.active:hover {
    background-color: #e8e8e8
  }

  .table>tbody>tr.success>td, .table>tbody>tr.success>th, .table>tbody>tr>td.success, .table>tbody>tr>th.success, .table>tfoot>tr.success>td, .table>tfoot>tr.success>th, .table>tfoot>tr>td.success, .table>tfoot>tr>th.success, .table>thead>tr.success>td, .table>thead>tr.success>th, .table>thead>tr>td.success, .table>thead>tr>th.success {
    background-color: #dff0d8
  }

  .table-hover>tbody>tr.success:hover>td, .table-hover>tbody>tr.success:hover>th, .table-hover>tbody>tr:hover>.success, .table-hover>tbody>tr>td.success:hover, .table-hover>tbody>tr>th.success:hover {
    background-color: #d0e9c6
  }

  .table>tbody>tr.info>td, .table>tbody>tr.info>th, .table>tbody>tr>td.info, .table>tbody>tr>th.info, .table>tfoot>tr.info>td, .table>tfoot>tr.info>th, .table>tfoot>tr>td.info, .table>tfoot>tr>th.info, .table>thead>tr.info>td, .table>thead>tr.info>th, .table>thead>tr>td.info, .table>thead>tr>th.info {
    background-color: #d9edf7
  }

  .table-hover>tbody>tr.info:hover>td, .table-hover>tbody>tr.info:hover>th, .table-hover>tbody>tr:hover>.info, .table-hover>tbody>tr>td.info:hover, .table-hover>tbody>tr>th.info:hover {
    background-color: #c4e3f3
  }

  .table>tbody>tr.warning>td, .table>tbody>tr.warning>th, .table>tbody>tr>td.warning, .table>tbody>tr>th.warning, .table>tfoot>tr.warning>td, .table>tfoot>tr.warning>th, .table>tfoot>tr>td.warning, .table>tfoot>tr>th.warning, .table>thead>tr.warning>td, .table>thead>tr.warning>th, .table>thead>tr>td.warning, .table>thead>tr>th.warning {
    background-color: #fcf8e3
  }

  .table-hover>tbody>tr.warning:hover>td, .table-hover>tbody>tr.warning:hover>th, .table-hover>tbody>tr:hover>.warning, .table-hover>tbody>tr>td.warning:hover, .table-hover>tbody>tr>th.warning:hover {
    background-color: #faf2cc
  }

  .table>tbody>tr.danger>td, .table>tbody>tr.danger>th, .table>tbody>tr>td.danger, .table>tbody>tr>th.danger, .table>tfoot>tr.danger>td, .table>tfoot>tr.danger>th, .table>tfoot>tr>td.danger, .table>tfoot>tr>th.danger, .table>thead>tr.danger>td, .table>thead>tr.danger>th, .table>thead>tr>td.danger, .table>thead>tr>th.danger {
    background-color: #f2dede
  }

  .table-hover>tbody>tr.danger:hover>td, .table-hover>tbody>tr.danger:hover>th, .table-hover>tbody>tr:hover>.danger, .table-hover>tbody>tr>td.danger:hover, .table-hover>tbody>tr>th.danger:hover {
    background-color: #ebcccc
  }

  .table-responsive {
    min-height: .01%;
    overflow-x: auto
  }

まとめ

利用規約とか軽い日記とか労力かけたくないけど見栄えよくしたい場合にいいと思います。
HTMLかけなくてもコマンド使えればなんとかなる
コマンド使えない人も使えるようにするなら管理画面作ってあげたほうがいいけど、静的ファイル更新するだけなのでそこまで難しくなさそう

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む