- 投稿日:2020-02-25T23:55:36+09:00
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" ] }
- 投稿日:2020-02-25T22:08:13+09:00
Rails & JS系(Vueとか)のフレームワークで未来の時間のテストをする方法
Rails側(サーバサイド)
Timecopを導入。
とりあえずGemfileに1行挿入。Gemfile(前略) group :development, :test do (中略) gem 'timecop' #この一行をdevelopmentとかstagingの中に書く (後略) endStaging環境の場合は、当然staging.rbに記述してください。
config/environments/development.rbRails.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 endJS側(クライアントサイド)
力技。OSの時間設定をいじれ!
取り敢えずMacだとシステム環境設定から。
こんな時に便利
予定されたリリースをシミュレートしたり、月初の処理が適切に行われているか等の確認に使える。
翌月にならないとテストできない、リリース当日のぶっつけ本番は避けたいですよね。
- 投稿日:2020-02-25T17:30:22+09:00
Vue.js切り替えタブを、一つのコンポーネントに書く
基礎から学ぶVue.jsの切り替えタブを、作り替えたい
参考にするお手本
■基礎から学ぶVue.js 切り替えタブ実装例
https://cr-vue.mio3io.com/examples/tab.html#%E3%83%87%E3%83%A2上記の実装例では、
・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
なるほど、JSの機能なのですね。
以上
ありがとうございました。
- 投稿日:2020-02-25T11:52:42+09:00
DjangoRestFrameWork + SendGridでお手軽お問い合わせフォームを爆速で作る
はじめに
私が個人Webサービスを作っている上での作業メモ。技術書典用のネタの下書きでもある。
フロントはVue.jsです。そのうちNuxtの方に移行したいですが・・・関係ないので省略します。お問い合わせフォームって?
で、これです。これの事を言ってます。割といろんなサイトで見かけますよね。
このお問い合わせフォームからメール配信サービスであるSendGridを経由して、このフォームの内容を管理人にメールで発信しよう、というのがこの記事の主題です。フロント側の方のコードは特に書く必要が無いと考えていますので、今回の記事では省略いたします。
また、SPFやDKIM等の送信ドメイン認証周りの設定方法も省略とさせてください。書いてる人もその辺り勉強中なので・・・。
今回したい事の図
雑な図で恐縮です。
流れとしては
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=10084DynamicTemplatesについて
簡単に言うと、SendGrid側でHTMLメールのテンプレートを用意してくれる機能です。そのまんまですね。
バージョニング機能、パラメータ設定機能などなど・・便利な機能盛りだくさんです。後段で説明します。個人的には開発者のコーディング品質向上の観点上、非常に重要な機能だと考えています。それも後で説明しますね。
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'] }ご覧の通りマスタッシュ記法で指定するパラメータを書けばよいです。
動作検証
フロントでフォームで問い合わせを発射する
最後の方説明が超雑になりましたが、何か聞きたい事等があればお知らせください。
よろしくお願いいたします。
- 投稿日:2020-02-25T11:14:34+09:00
顔認識していろんなリップを試せるアプリの開発と挫折
概要
作ったものはこちら(https://touchlip.koatech.info/)
対面カメラやアップロードした画像に対し、色々なリップを合わせることができます。
こういったソリューションはPerfect社さんが圧倒的ですが、再発明してみました。Perfectさんの製品は、CHANELのサイトを見ていただければ明らかですが、ラメやマット感など、リップによって異なる質感をうまく再現しています。僕が作ったのは残念ながら色を塗るだけです。
face-api.jsを使って顔認識も描画も全部クライアントサイドで完結しています。
Netlifyの無料枠がまだまだ余裕なので運用コストも無料です。以下、頑張ったところをメモ。
リップを塗る処理
- 顔認識して顔のパーツのランドマークを取る
どこにLandmarksのindex何番が表示されているのかを特定する
どうlandmarksを繋げば綺麗に唇を描けるかを特定する
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) },リップが点滅する
当初の実装と事象
対面カメラの映像にリップを塗るために以下を実装します。
videoの内容をcanvasに表示
顔認識
唇のランドマーク取得
リップ描画
1~4をsetIntervalで繰り返す
すると、前のintervalの4で書き込んだリップは、次のintervalの1で書き込まれたvideoに隠れてしまいます。
つまり、2と3の顔認識でラグればラグるほど、リップをvideoで上書きしてからもう一度リップを描画するまでが遅くなってしまい、ユーザーには、リップが点滅しているように見えてしまいます。
解決方法
唇のランドマークを取得したらその位置をキャッシュし、1の後すぐに描画する。
- videoの内容をcanvasに表示
- 顔認識結果がキャッシュされてたらリップ描画
- 顔認識
- 認識結果をキャッシュ
- 唇のランドマーク取得
- リップ描画
- 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
- 顔認識
- 投稿日:2020-02-25T07:35:10+09:00
Firebase チュートリアル FriendlyEats-vue
FriendlyEats-vue
1. FriendlyEats-vue について
FriendlyEats-vueは、Vueを使ったFirebase / Cloud Firestoreのチュートリアル用のアプリです。Cloud Firestoreを学習するために最小限のプログラムをするだけでCloud Firestoreを使ったアプリケーションを作ることができます。
このチュートリアルでは以下のことを学習します。
- 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を作成する
- Firebaseのコンソール上で「プロジェクトを追加」をクリックします
- プロジェクト名前を入力します。「FriendlyEats」と入力してください
- 入力したプロジェクト名の下にプロジェクトIDが表示されます(変更可能です) 作成プロジェクトIDは忘れないように!
- [続行]をクリックします
- Google アナリティクス画面で「今は必要ない」を選択します
- [プロジェクトを作成]をクリックします
- 「新しいプロジェクトの準備ができました」が表示されます。[続行]をクリックします
重要: 作成された Firebaseのプロジェクトは「FriendlyEats」という名前ですが、Firebaseは自動的に「friendlyeats-1234」のような固有のプロジェクトIDを割り当てます。この固有のIDは、あなたのプロジェクトを識別するのに必要です(CLIなどで)。「FriendlyEats」は単にプロジェクトの名前です。
これから作成するアプリケーションでは、ウェブ上で使えるFirebaseのサービスのうちいくつかを利用します。
- Firebase Authentication - ユーザーを簡単に管理/識別します
- Cloud Firestore - クラウド上に構造化されたデータを保存し、データが更新された時は即座に通知します
- Firebase Hosting - 静的なコンテンツをホスティングします
以下では、Firebaseコンソールを用いた「Firebase Auth」および「Cloud Firestore」の設定方法について、順を追って説明します。
Anonymous Auth (匿名認証)を有効にする
認証はこのチュートリアルの焦点ではありませんが、何らかの形式の認証を使用することは重要です。
このアプリでは、匿名ログインを使用します。つまりユーザーは明示的な操作をすることなくログインします。そのためには、匿名ログインを有効にする必要がありまず。
- ブラウザで、Firebaseのコンソールを表示します
- 左のナビゲーションメニュー「開発」の「Authentication」をクリックします
- 「ログイン方法」タブをクリックします
- 「ログインプロバイダ」の「匿名」をクリックし「有効」にしてください
- 最後に[保存]をクリックします
これでユーザーがWebアプリにアクセスするときに、匿名でログインできるようになりました。詳細は、匿名認証のドキュメントをお読みください。
Cloud Firestoreを有効にする
このアプリは、レストランの情報や評価を保存、更新情報を受け取るために、Cloud Firestore(データーベース)を使います。
そのためには、Cloud Firestoreを有効にする必要があります。
- ブラウザで、Firebaseコンソールを表示します
- 左のナビゲーションメニュー「開発」の「Database」をクリックします
- Cloud Firestoreペインで「データベースの作成」をクリックします
- オプションの「テストモードで開始」を選択し、セキュリティルールに関する免責事項を読んだ後、[次へ]をクリックします
- ロケーションを選択し(デフォルトのままでも構いませんが、後から変更することはできません)、[完了]をクリックします
テストモードでは、開発中にCloud Firestoreへ書き込みが自由にできるようになります。セキュリティを強化は、このチュートリアルの後半でおこないます。
3. サンプルのソースコード取得とインストール
ソースコードを取得する
以下のコマンドを使って GitHub レポジトリをクローンします
git clone https://github.com/isamu/FriendlyEats-vue
- 自分の変更をGitHubで管理したい場合には、Forkしてcloneしてください
サンプルコードは?FriendlyEats-vueディレクトリにCloneする必要があります。
以後、このディレクトリ内でコマンドラインを実行してください。cd FriendlyEats-vuenpmをインストールする
npmのパッケージをインストールします。
npm installFirebaseの設定を取得し 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 --versionFirebase CLIのバージョンがv6.2.0以降であることを確認してください。
3 . 次のコマンドを実行して、Firebase CLIを認証します。
firebase loginFirebase Hostingのアプリの設定をアプリのローカルディレクトリとファイルから取得するように、ウェブアプリテンプレートを設定しました。ただし、これを行うには、アプリをFirebaseプロジェクトに関連付ける必要があります。
4 . コマンドラインが、先ほどcloneしたディレクトリーになっているか確認してください(通常FriendlyEats-vueディレクトリー。pwdで現在のディレクトリーを確認できます)
5 . 次のコマンドを実行して、アプリをFirebaseプロジェクトに関連付けます。
firebase use --add6 . プロンプトが表示されたら、本プロジェクトのプロジェクトIDを選択し、Firebaseプロジェクトにエイリアスを指定します。
エイリアスは、複数の環境(本番、ステージングなど)を切り替える場合に役立ちます。ただし、このチュートリアルでは、default
というエイリアス名を入力します(スペルを間違えると後にインデックスをデプロイする操作等でエラーが発生するので注意してください)。7 . コマンドラインの残りの指示に従ってください。
5. Vueをローカルで起動する
アプリで実際に作業を開始する準備ができました!アプリをローカルで実行しましょう!
1 . 次のコマンドをローカルのCLIで実行します:
npm run serve2 . 成功すると次の文を含むメッセージが表示されます
- Local: http://localhost:8080/Vueサーバがローカルで起動しています。 ブラウザ http://localhost:8080 を開くとサンプルアプリを見ることができます。 Vueを起動すると自動的に開く場合もあります。8080という数字は少し別の番号になっている場合もあります。
3 . ブラウザで http://localhost:8080 を見る
クラウド上のFirebaseプロジェクトに接続されているFriendlyEatsアプリが表示されます(初回起動時はしばらく時間がかかる場合があります)。
アプリは自動的にクラウド上のFirebaseプロジェクトに接続し、匿名ユーザーとしてサインインしました。
6. Cloud Firestoreへデータの書き込み
このセクションでは、Cloud Firestoreにデータを書き込みます。Firebaseコンソール上で手動でデータ入力を行うこともできますが、Cloud Firestoreの基本的な書き込みを学習する為に、アプリ自体でデータ生成/入力を行います。
データモデル
Firestoreデータは、コレクション、ドキュメント、フィールド、およびサブコレクションで構成されています。各レストラン情報をドキュメントとして、
restaurants
と呼ばれる最上位のコレクションに保存します。そして、各レストランのレビューを
ratings
と名付けたサブコレクションに保存します。Tip: Firestoreデータモデルの詳細については、ドキュメントのドキュメントとコレクションをご覧ください。
Firestoreにレストラン情報を追加する
このアプリの主なモデルオブジェクトはrestaurantです。
restaurants
コレクションにレストランのドキュメントを追加するコードを書きましょう。
- cloneしたソースコードの
src/components/FriendlyEats.Data.js
ファイルを開きますaddRestaurant
関数を探します- 関数全体を以下のコードに置き換えます
export const addRestaurant = (data) => { const collection = firebase.firestore().collection('restaurants'); return collection.add(data); };上記のコードにより、
restaurants
コレクションに新しいドキュメント(データ)が追加されます。ドキュメントのデータはJavaScriptオブジェクトです。この関数は、次のような処理をします。
- レストランのデータを引数として取得します
- Cloud Firestoreの
restaurants
コレクションへの参照を取得します- 引数で受け取ったデータは、レストランオブジェクトとしてランダムに自動生成し、ドキュメントに追加します
(* 実際にどのようにデータが生成されるか興味がある人は
src/FriendlyEats/FriendlyEats.Mock.js
のaddMockRestaurants
とgetRandomRestaurant
の実装を見てください。)restaurants情報を追加しよう!
- ブラウザのFriendlyEatsアプリに戻り、画面を更新します
- 「IMPORT DATA」をクリックします
まだ画面には何も表示されませんが、Cloud Firestoreにはデータが登録されているはずです。
実際にみてみましょう。
Firebaseコンソールの「Cloud Firestore」タブに移動すると、
restaurants
コレクションに新しいドキュメントが表示されます。おめでとうございます!!WebアプリからCloud Firestoreにデータを書き込みが成功しました!!
次のセクションでは、Cloud Firestoreからデータを取得してアプリに表示する方法を学習します。
7. Cloud Firestore のデータを表示
このセクションでは、Cloud Firestoreからデータを取得してアプリに表示する方法を学習します。 2つの重要な手順は、クエリの作成とスナップショットリスナーの追加です。このリスナーには、クエリに一致するすべての既存データが通知され、更新をリアルタイムで受信します。
最初に、レストランのデフォルトのフィルタリングされていないリストを提供するクエリを作成しましょう。
src/components/FriendlyEats.Data.js
ファイルを開きますgetAllRestaurants
関数を探します- 関数全体を以下のコードに置き換えます
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
関数を探します- 関数全体を以下のコードに置き換えます
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.type
はremoved
となります。したがって、この場合、UIからレストランを削除する関数を呼び出します。両方のメソッドを実装したので、アプリを更新し、Firebaseコンソールで前に表示したレストラン情報がWebアプリに表示されていることを確認します。このセクションを正常に完了した場合、WebアプリはCloud Firestoreでデータを読み書きできています。
レストランのリストが変更されると、このリスナーは自動的にデータを更新します。
Firebaseコンソールに移動して、レストランを手動で削除するか、名前を変更してみてください。サイト上のデータも更新されます。Note:
Query.get()
メソッドを使用することにより、更新通知を常時リアルタイムに受け取るのではなく、Cloud Firestoreからドキュメントを一度だけ取得することもできます。8. データを取得する
ここまでは、
onSnapshot
を使用して更新をリアルタイムで取得する方法を実装しました。
つぎは、アプリ内の特定のレストランをクリックした時にトリガーされる機能を実装しましょう。
src/components/FriendlyEats.Data.js
を開きますgetRestaurant
関数を探します- 関数全体を以下のコードに置き換えます
export const getRestaurant = (id) => { return firebase.firestore().collection('restaurants').doc(id).get(); };このメソッドを実装すると、各レストランのページを表示できるようになります。リスト内のレストランをクリックするだけで、レストランの詳細ページが表示されます。
現時点では評価を追加することはできませんが、この機能はチュートリアルの後半で実装します。
9. データのソートと絞り込み
今のところ、アプリにはレストランのリストが表示されていますが、ユーザーがニーズに基づいてフィルタリングする方法はありません。このセクションでは、Cloud Firestoreの高度なクエリを使用してフィルタリングを有効にします。
すべての「点心(Dim Sum)」レストランを取得する簡単なクエリの例を次に示します。
var filteredQuery = query.where('category', '==', 'Dim Sum')その名前が示すように、
where()
メソッドは、条件に一致するフィールドを持つコレクション内のドキュメントを取得します。この場合、カテゴリが「点心(Dim Sum)」のレストランのみを取得しています。このアプリでは、ユーザーは複数のフィルターをチェーンして、「サンフランシスコのピザ」や「人気のあるロサンゼルスのシーフード」などの特定のクエリを作成できます。
それでは、ユーザーが選択した複数の条件に基づいてレストランをフィルタリングするクエリを作成するメソッドを作成してみましょう。
src/components/FriendlyEats.Data.js
を開きますgetFilteredRestaurants
を探します- 関数全体を以下のコードに置き換えます
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ファイルがあります。このファイルには、フィルターに必要なすべてのインデックスが記述されています。
{ "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には、単一のアトミック操作で複数の読み取りと書き込みを可能にするトランザクション機能が用意されており、これによりデータの一貫性を維持できます。
src/components/FriendlyEats.Data.js
を開くaddRating
関数を探す- 関数全体を以下のコードに置き換えます
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
ドキュメントのaverageRating
とratingCount
の数値を更新するトランザクションを呼び出します。同時に、ratings
サブコレクションに新しいrating
を追加します。注:評価の追加にトランザクションを使うことは、このチュートリアルのケースでは良い使用例です。ただし、実運用アプリでは、ユーザーによる不正操作を避けるために、平均評価の算出は信頼できるサーバーで行う必要があります。これを行う良い方法は、クライアントから直接評価ドキュメントを作成し、Cloud Functionsを利用して新しいレストランの平均評価を更新することです。
警告:サーバーでトランザクションが失敗すると、コールバックも繰り返し再実行されます。アプリの状態を変更するロジックをトランザクションコールバック内に配置しないでください。
12. データを守る
このチュートリアルの最初に、アプリのセキュリティルールをテストモードに設定し、自由に読み書きできるようにしました。
実際のアプリケーションでは、望ましくないデータの読み込みや変更を防ぐために、よりきめ細かいルールを設定する必要があります。
- Firebase console を開き、開発 > Database を選択します
- Cloud Firestore > ルール タブをクリックします
rules_version = '2';
より下のコードを以下のルールに置き換えて「公開」をクリックします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.comHosting URLをブラウザで見てみましょう。作成したアプリケーションが見えます!
14. まとめ
このチュートリアルでは、Cloud Firestoreで基本的な、そして高度な読み取りと書き込みを行う方法と、セキュリティルールでデータアクセスを保護する方法を学びました。
Cloud Firestore の詳細については、次のリソースをご覧ください:
- 投稿日:2020-02-25T01:16:07+09:00
jestでvueファイルのカバレッジが収集されない問題への対処
概要
最近になって Codecov というサービスを知ってテストのカバレッジに気を使うようになったのですが、Jestを使って*.vueのカバレッジを取ろうとしても無視されるような動作に悩まされていました。
(おそらくはv25など特定のバージョンに起因する問題)2020年2月時点の話で、直近のアップデートで改善されると思われるものの
メモしておきます。
図: カバレッジに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ファイルのカバレッジが取れていることを確認できます。参考情報
各種バージョン
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" } }
- 投稿日:2020-02-25T00:22:32+09:00
【Vue.js】HTMLかけない人でも静的ファイルを読み込んでいい感じの記事を書けるようにする
HTMLかけない人でも静的ファイルを読み込んでいい感じの記事を書けるようにする
動機
- HTMLかけない人でもいい感じの記事やテキストページを書けるようにしたい
- HTMLかける人でもテキストページくらいは労力減らして書けるようにしたい
- 利用者自身で好きに編集できるようにしたい
- 内容をDBに保存したりとかAPI連携するとか考えたくない
- 管理画面ちゃんと作るのめんどくさい
実現したもの
- Markdown使う
- .mdの静的ファイルを読み込むようにしたのでファイル上書きすれば更新可能
デモ
インポートしてる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かけなくてもコマンド使えればなんとかなる
コマンド使えない人も使えるようにするなら管理画面作ってあげたほうがいいけど、静的ファイル更新するだけなのでそこまで難しくなさそう