LoginSignup
5
1

More than 5 years have passed since last update.

Polymer サンプルコード (5) PWA [2]

Last updated at Posted at 2017-08-29

PWA (Progressive Web Application) [2]

PolymerでMarkdown形式のテキストをページ毎に整形して切り替えつつ、編集もできるPWAを作ってみます。
ビルドツール(Polymer CLI)を使って、 FirebaseリアルタイムDB認証ホスティングを使用します。

Polymer サンプルコード (4) PWA [1] の続きです。

動作確認
Windows 10 : ○ Chrome60 Firefox56 × Edge40 IE11
Mac 10.3 : ○ Chrome60 Safari10.1
Android 7.1 : ○ Chrome60

画面イメージ

qiita-fb-pwa2.gif

:globe_with_meridians: デモサイト (Firebase)
・Googleでログインすれば編集できますが、いたずら無しでお願いします
・定期的にデータクリアしてます

機能

image.png

  • Lighthouse PWA 100点
  • Markdownの表示(gfmgithub-markdown-css、コードハイライト)
  • フレンドリーURL(OGPは未対応、GoogleBotもちゃんと描画してくれてないみたい)
  • リアルタイム編集(ユーザ認証、サニタイズ)

実行ステップ

ビルドツール(Polymer CLI)やFirebaseのプロジェクトの準備は前回と同じです。

1. 認証とリアルタイムDBへデータ設定

image.png

Firebaseの管理コンソールの左メニューから「Authentication」を選択し、「ログイン方法」→ログインプロパイダで「Google」を「有効」にします。

image.png

続いて左メニューから「Database」を選択し、表示するデータとセキュリティのルールを設定します。

  • データの登録
{
  "data" : {
    "view1" : {
      "contents" : "# こんにちわ\n\nこれは1ページ目です。",
      "title" : "Page 1"
    },
    "view2" : {
      "contents" : "# 2ページ目\n\nこれはサンプルのテキストです。\n\n## 見出し1\n\n[リンク](https://www.google.com/)も書けます。\n\n## 見出し2\n\n**強調表示**\n",
      "title" : "Page 2"
    },
    "view3" : {
      "contents" : "# 3ページ目\n\nこれはサンプルのテキストです。\n\n```html\n<hello>hello world!</hello>\n```\n",
      "title" : "Page 3"
    }
  }
}

上記のJSON形式のデータをインポートします。下記のようにコピペで作成もできます(ちょっとコツがいりますが、、、)。

qiita-fb-pwa2-cp.gif

  • ルールの設定

初期状態では認証されたユーザしか読み書きできないので、「ルール」タブをクリックし、下記のルールをペーストして上書き、「公開」ボタンをクリックしてください(/data以下のデータの読み込みを許可し、認証されたユーザのみ書きこみを許可します)。

{
  "rules": {
    ".read": false,
    ".write": false,
    "data": {
        ".read": true,
        ".write": "auth != null"
    }
  }
}

ドメインや個人でアクセス制限をするには下記のように書き換えます。

・個人に制限する場合

".write": "auth != null && auth.token.email == 'hoge@gmail.com'"

・ドメインで制限する場合

".write": "auth != null && auth.token.email.endsWith('@hoge.co.jp') != -1"

2. ファイルの取得とAPIキーの設定

ファイル一式をこちらからダウンロード(sample-2ブランチ)するか、後述のサンプルコードからファイルを作成してください。
src/my-fire.html<firebase-app>タグのapi-keyauth-domaindatabase-urlはご自分のプロジェクトのものを使用してください。

# githubからダウンロードする場合
git clone -b sample-2 https://github.com/howking/polymer-sample-pwa sample-2

# 作業ディレクトリに移動
cd sample-2

# my-fire.htmlを修正
emacs src/my-fire.html
src/my-fire.html
    <firebase-app
      api-key="AIzaSyAXoRzCFBT2eGSZCKnl5Rspz7Cg-cUvtU8"
      auth-domain="sample-2-8952f.firebaseapp.com"
      database-url="https://sample-2-8952f.firebaseio.com"></firebase-app>

書換える文字列はプロジェクトページのトップ画面から「ウェブアプリにFirebaseを追加」をクリックし、

image.png

表示されたapiKeyauthDomaindatabaseURLの値を使用してください。

image.png

3. ビルドとファイルのアップロード

下記を入力するとビルドされ、ファイルがアップロードされます。

# 利用するWebライブラリを取得
bower install

# ローカルでサーバを立ち上げて動作確認 → http://localhost:8000/
polymer serve

# アップロードするファイルを構築
polymer build

# !!注意!! polymerfireのビルドにはバグがあるので、ビルド後のファイルを修正します。。。
# https://github.com/Polymer/polymer-cli/issues/701
perl -pi -e 's/ks\(this\);var lI=this.Hd;if\(lI\)for\(var uI=\[\],hI=1;/ks(this);var uI=[];var lI=this.Hd;if(lI)for(var hI=1;/' build/default/src/my-view.html

# Firebaseにログイン
firebase login

# 使用するプロジェクトを指定
firebase use [プロジェクトID]

# ファイルをアップロード
firebase deploy

https://[プロジェクトID].firebaseapp.com/ で確認できます。 :tada:

サンプルコード

files
├── README.md
├── bower.json
├── firebase.json
├── index.html
├── manifest.json
├── polymer.json
├── service-worker.js
├── src
│   ├── my-fire.html
│   ├── my-home.html
│   ├── my-icons.html
│   ├── my-marked.html
│   ├── my-theme.html
│   └── my-view.html
└── sw-precache-config.js
  • index.html トップページ、 <my-view>タグの呼出し元
index.html
<!doctype html>
<html lang="ja">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Polymer Sample PWA</title>
    <link rel="manifest" href="/manifest.json">
    <meta name="theme-color" content="#29549a">
  </head>
  <body>
    <my-view>
      <svg viewBox="0 0 24 30" fill="#eee" width="128"
           style="position: absolute; top: 0px; right: 0px; bottom: 0px; left: 0px; margin: auto;">
        <path d="M19 4h-4L7.11 16.63 4.5 12 9 4H5L.5 12 5 20h4l7.89-12.63L19.5 12 15 20h4l4.5-8z"/>
      </svg>
      <noscript>Polymer Sample PWA</noscript>
    </my-view>
    <script src="/bower_components/webcomponentsjs/webcomponents-loader.js"></script>
    <link rel="import" href="/src/my-view.html">
    <script>
      if ('serviceWorker' in navigator) {
        window.addEventListener('load', function() {
          navigator.serviceWorker.register('/service-worker.js');
        });
      }
    </script>
  </body>
</html>
  • src/my-view.html <my-view>タグを表示するHTML
src/my-view.html
<link rel="import" href="../bower_components/polymer/polymer-element.html">
<link rel="import" href="../bower_components/polymer/lib/elements/dom-if.html">
<link rel="import" href="../bower_components/polymer/lib/elements/dom-repeat.html">
<link rel="import" href="../bower_components/app-route/app-location.html">
<link rel="import" href="../bower_components/app-route/app-route.html">
<link rel="import" href="../bower_components/app-layout/app-header-layout/app-header-layout.html">
<link rel="import" href="../bower_components/app-layout/app-drawer-layout/app-drawer-layout.html">
<link rel="import" href="../bower_components/app-layout/app-header/app-header.html">
<link rel="import" href="../bower_components/app-layout/app-toolbar/app-toolbar.html">
<link rel="import" href="../bower_components/app-layout/app-drawer/app-drawer.html">
<link rel="import" href="../bower_components/paper-icon-button/paper-icon-button.html">
<link rel="import" href="../bower_components/paper-listbox/paper-listbox.html">
<link rel="import" href="../bower_components/paper-item/paper-item.html">
<link rel="import" href="my-home.html">
<link rel="import" href="my-icons.html">
<link rel="import" href="my-fire.html">
<link rel="import" href="my-marked.html" async>

<dom-module id="my-view">
  <template strip-whitespace>
    <style>
      :host {
        display: block;
        --app-primary-color: #29549a;
      }
      app-header {
        background-color: var(--app-primary-color);
        color: white;
      }
      app-header paper-icon-button {
        --paper-icon-button-ink-color: white;
      }
      my-home { display: block; margin-left: 10px; }
      div[main-title] { margin-left: 10px; }
      a paper-icon-button { text-decoration: none; color: white; }
      paper-listbox a { text-decoration: none; color: black; }
      paper-listbox a.iron-selected { color: lightgray; }
      paper-item:not(.iron-selected) { cursor: pointer; }
    </style>

    <app-location route="{{route}}"></app-location>
    <app-route route="{{route}}" pattern="/:key" data="{{routeData}}"></app-route>

    <app-drawer-layout force-narrow fullbleed>

      <app-drawer slot="drawer" swipe-open>
        <app-header fixed>
          <app-toolbar>
            <a href="/"><paper-icon-button icon="my:home" drawer-toggle></paper-icon-button></a>
          </app-toolbar>
        </app-header>
        <paper-listbox>
          <template is="dom-repeat" items="[[pages]]">
            <a href="/[[item.$key]]"><paper-item drawer-toggle>[[item.title]]</paper-item></a>
          </template>
        </paper-listbox>
      </app-drawer>

      <app-header-layout has-scrolling-region>
        <app-header slot="header" reveals>
          <app-toolbar>
            <paper-icon-button icon="my:menu" drawer-toggle></paper-icon-button>
            <div main-title>My app</div>
            <paper-icon-button icon="my:delete"></paper-icon-button>
            <paper-icon-button icon="my:search"></paper-icon-button>
            <my-fire key="[[routeData.key]]" pages="{{pages}}" view="{{view}}" login="{{login}}"></my-fire>
          </app-toolbar>
        </app-header>

        <template is="dom-if" if="[[!routeData.key]]">
          <my-home></my-home>
        </template>

        <template is="dom-if" if="[[routeData.key]]">
          <my-marked contents="{{view.contents}}" login="[[login]]"></my-marked>
        </template>

      </app-header-layout>

    </app-drawer-layout>
  </template>

  <script>
    class MyView extends Polymer.Element {
      static get is() { return 'my-view' }
    }
    customElements.define(MyView.is, MyView)
  </script>
</dom-module>
  • src/my-marked.html <my-marked>タグを表示するHTML
src/my-marked.html
<link rel="import" href="../bower_components/polymer/polymer-element.html">
<link rel="import" href="../bower_components/polymer/lib/elements/dom-if.html">
<link rel="import" href="../bower_components/marked-element/marked-element.html">
<link rel="import" href="../bower_components/prism-element/prism-highlighter.html">
<link rel="import" href="../bower_components/prism-element/prism-theme-default.html">
<link rel="import" href="../bower_components/sanitize-element/sanitize-element.html">
<link rel="import" href="../bower_components/paper-toggle-button/paper-toggle-button.html">
<link rel="import" href="../bower_components/paper-input/paper-textarea.html">

<!-- https://poly-style.appspot.com?id=github-markdown-styles&url=https://cdnjs.cloudflare.com/ajax/libs/github-markdown-css/2.8.0/github-markdown.min.css -->
<link rel="import" href="my-theme.html">

<dom-module id="my-marked">
  <template>
    <style include="prism-theme-default my-theme">
      :host {
        display: block;
        margin: 10px 15px;
        --paper-input-container: {
          width: 100%;
        }
      }
      paper-toggle-button {
        margin-top: 15px;
      }
    </style>

    <prism-highlighter></prism-highlighter>

    <sanitize-element
      sanitizer="{{_sanitizer}}"
      config='{"elements":["my-home","img"],
               "attributes":{"img":["class","src"]},
               "protocols":{"img":{"src":["https"]}}}'></sanitize-element>

    <marked-element sanitize sanitizer="[[_sanitizer]]" markdown="[[contents]]">
      <main class="markdown-body" slot="markdown-html"></main>
    </marked-element>

    <template is="dom-if" if="[[contents]]">
      <template is="dom-if" if="[[login]]">
        <paper-toggle-button checked="{{_edit}}"></paper-toggle-button>
        <paper-textarea value="{{contents}}" hidden="[[!_edit]]"></paper-textarea>
      </template>
    </template>

  </template>
  <script>
    class MyMarked extends Polymer.Element {
      static get is() { return 'my-marked' }
      static get properties() {
        return {
          login: Boolean,
          contents: {
            type: String,
            notify: true
          }
        }
      }
    }
    customElements.define(MyMarked.is, MyMarked)
  </script>
</dom-module>
  • src/my-theme.html github-markdownのCSSファイル
src/my-theme.html
<dom-module id="my-theme">
  <template>
    <style>
      @font-face{font-family:octicons-link;src:url(data:font/woff;charset=utf-8;base64,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) format('woff')}.markdown-body{-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%;line-height:1.5;color:#24292e;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol";font-size:16px;line-height:1.5;word-wrap:break-word}.markdown-body .pl-c{color:#6a737d}.markdown-body .pl-c1,.markdown-body .pl-s .pl-v{color:#005cc5}.markdown-body .pl-e,.markdown-body .pl-en{color:#6f42c1}.markdown-body .pl-s .pl-s1,.markdown-body .pl-smi{color:#24292e}.markdown-body .pl-ent{color:#22863a}.markdown-body .pl-k{color:#d73a49}.markdown-body .pl-pds,.markdown-body .pl-s,.markdown-body .pl-s .pl-pse .pl-s1,.markdown-body .pl-sr,.markdown-body .pl-sr .pl-cce,.markdown-body .pl-sr .pl-sra,.markdown-body .pl-sr .pl-sre{color:#032f62}.markdown-body .pl-smw,.markdown-body .pl-v{color:#e36209}.markdown-body .pl-bu{color:#b31d28}.markdown-body .pl-ii{color:#fafbfc;background-color:#b31d28}.markdown-body .pl-c2{color:#fafbfc;background-color:#d73a49}.markdown-body .pl-c2::before{content:"^M"}.markdown-body .pl-sr .pl-cce{font-weight:700;color:#22863a}.markdown-body .pl-ml{color:#735c0f}.markdown-body .pl-mh,.markdown-body .pl-mh .pl-en,.markdown-body .pl-ms{font-weight:700;color:#005cc5}.markdown-body .pl-mi{font-style:italic;color:#24292e}.markdown-body .pl-mb{font-weight:700;color:#24292e}.markdown-body .pl-md{color:#b31d28;background-color:#ffeef0}.markdown-body .pl-mi1{color:#22863a;background-color:#f0fff4}.markdown-body .pl-mc{color:#e36209;background-color:#ffebda}.markdown-body .pl-mi2{color:#f6f8fa;background-color:#005cc5}.markdown-body .pl-mdr{font-weight:700;color:#6f42c1}.markdown-body .pl-ba{color:#586069}.markdown-body .pl-sg{color:#959da5}.markdown-body .pl-corl{text-decoration:underline;color:#032f62}.markdown-body .octicon{display:inline-block;vertical-align:text-top;fill:currentColor}.markdown-body a{background-color:transparent;-webkit-text-decoration-skip:objects}.markdown-body a:active,.markdown-body a:hover{outline-width:0}.markdown-body strong{font-weight:inherit}.markdown-body strong{font-weight:bolder}.markdown-body h1{font-size:2em;margin:.67em 0}.markdown-body img{border-style:none}.markdown-body svg:not(:root){overflow:hidden}.markdown-body code,.markdown-body kbd,.markdown-body pre{font-family:monospace,monospace;font-size:1em}.markdown-body hr{box-sizing:content-box;height:0;overflow:visible}.markdown-body input{font:inherit;margin:0}.markdown-body input{overflow:visible}.markdown-body [type=checkbox]{box-sizing:border-box;padding:0}.markdown-body *{box-sizing:border-box}.markdown-body input{font-family:inherit;font-size:inherit;line-height:inherit}.markdown-body a{color:#0366d6;text-decoration:none}.markdown-body a:hover{text-decoration:underline}.markdown-body strong{font-weight:600}.markdown-body hr{height:0;margin:15px 0;overflow:hidden;background:0 0;border:0;border-bottom:1px solid #dfe2e5}.markdown-body hr::before{display:table;content:""}.markdown-body hr::after{display:table;clear:both;content:""}.markdown-body table{border-spacing:0;border-collapse:collapse}.markdown-body td,.markdown-body th{padding:0}.markdown-body h1,.markdown-body h2,.markdown-body h3,.markdown-body h4,.markdown-body h5,.markdown-body h6{margin-top:0;margin-bottom:0}.markdown-body h1{font-size:32px;font-weight:600}.markdown-body h2{font-size:24px;font-weight:600}.markdown-body h3{font-size:20px;font-weight:600}.markdown-body h4{font-size:16px;font-weight:600}.markdown-body h5{font-size:14px;font-weight:600}.markdown-body h6{font-size:12px;font-weight:600}.markdown-body p{margin-top:0;margin-bottom:10px}.markdown-body blockquote{margin:0}.markdown-body ol,.markdown-body ul{padding-left:0;margin-top:0;margin-bottom:0}.markdown-body ol ol,.markdown-body ul ol{list-style-type:lower-roman}.markdown-body ol ol ol,.markdown-body ol ul ol,.markdown-body ul ol ol,.markdown-body ul ul ol{list-style-type:lower-alpha}.markdown-body dd{margin-left:0}.markdown-body code{font-family:SFMono-Regular,Consolas,"Liberation Mono",Menlo,Courier,monospace;font-size:12px}.markdown-body pre{margin-top:0;margin-bottom:0;font:12px SFMono-Regular,Consolas,"Liberation Mono",Menlo,Courier,monospace}.markdown-body .octicon{vertical-align:text-bottom}.markdown-body .pl-0{padding-left:0!important}.markdown-body .pl-1{padding-left:4px!important}.markdown-body .pl-2{padding-left:8px!important}.markdown-body .pl-3{padding-left:16px!important}.markdown-body .pl-4{padding-left:24px!important}.markdown-body .pl-5{padding-left:32px!important}.markdown-body .pl-6{padding-left:40px!important}.markdown-body::before{display:table;content:""}.markdown-body::after{display:table;clear:both;content:""}.markdown-body>:first-child{margin-top:0!important}.markdown-body>:last-child{margin-bottom:0!important}.markdown-body a:not([href]){color:inherit;text-decoration:none}.markdown-body .anchor{float:left;padding-right:4px;margin-left:-20px;line-height:1}.markdown-body .anchor:focus{outline:0}.markdown-body blockquote,.markdown-body dl,.markdown-body ol,.markdown-body p,.markdown-body pre,.markdown-body table,.markdown-body ul{margin-top:0;margin-bottom:16px}.markdown-body hr{height:.25em;padding:0;margin:24px 0;background-color:#e1e4e8;border:0}.markdown-body blockquote{padding:0 1em;color:#6a737d;border-left:.25em solid #dfe2e5}.markdown-body blockquote>:first-child{margin-top:0}.markdown-body blockquote>:last-child{margin-bottom:0}.markdown-body kbd{display:inline-block;padding:3px 5px;font-size:11px;line-height:10px;color:#444d56;vertical-align:middle;background-color:#fafbfc;border:solid 1px #c6cbd1;border-bottom-color:#959da5;border-radius:3px;box-shadow:inset 0 -1px 0 #959da5}.markdown-body h1,.markdown-body h2,.markdown-body h3,.markdown-body h4,.markdown-body h5,.markdown-body h6{margin-top:24px;margin-bottom:16px;font-weight:600;line-height:1.25}.markdown-body h1 .octicon-link,.markdown-body h2 .octicon-link,.markdown-body h3 .octicon-link,.markdown-body h4 .octicon-link,.markdown-body h5 .octicon-link,.markdown-body h6 .octicon-link{color:#1b1f23;vertical-align:middle;visibility:hidden}.markdown-body h1:hover .anchor,.markdown-body h2:hover .anchor,.markdown-body h3:hover .anchor,.markdown-body h4:hover .anchor,.markdown-body h5:hover .anchor,.markdown-body h6:hover .anchor{text-decoration:none}.markdown-body h1:hover .anchor .octicon-link,.markdown-body h2:hover .anchor .octicon-link,.markdown-body h3:hover .anchor .octicon-link,.markdown-body h4:hover .anchor .octicon-link,.markdown-body h5:hover .anchor .octicon-link,.markdown-body h6:hover .anchor .octicon-link{visibility:visible}.markdown-body h1{padding-bottom:.3em;font-size:2em;border-bottom:1px solid #eaecef}.markdown-body h2{padding-bottom:.3em;font-size:1.5em;border-bottom:1px solid #eaecef}.markdown-body h3{font-size:1.25em}.markdown-body h4{font-size:1em}.markdown-body h5{font-size:.875em}.markdown-body h6{font-size:.85em;color:#6a737d}.markdown-body ol,.markdown-body ul{padding-left:2em}.markdown-body ol ol,.markdown-body ol ul,.markdown-body ul ol,.markdown-body ul ul{margin-top:0;margin-bottom:0}.markdown-body li>p{margin-top:16px}.markdown-body li+li{margin-top:.25em}.markdown-body dl{padding:0}.markdown-body dl dt{padding:0;margin-top:16px;font-size:1em;font-style:italic;font-weight:600}.markdown-body dl dd{padding:0 16px;margin-bottom:16px}.markdown-body table{display:block;width:100%;overflow:auto}.markdown-body table th{font-weight:600}.markdown-body table td,.markdown-body table th{padding:6px 13px;border:1px solid #dfe2e5}.markdown-body table tr{background-color:#fff;border-top:1px solid #c6cbd1}.markdown-body table tr:nth-child(2n){background-color:#f6f8fa}.markdown-body img{max-width:100%;box-sizing:content-box;background-color:#fff}.markdown-body code{padding:0;padding-top:.2em;padding-bottom:.2em;margin:0;font-size:85%;background-color:rgba(27,31,35,.05);border-radius:3px}.markdown-body code::after,.markdown-body code::before{letter-spacing:-.2em;content:"\00a0"}.markdown-body pre{word-wrap:normal}.markdown-body pre>code{padding:0;margin:0;font-size:100%;word-break:normal;white-space:pre;background:0 0;border:0}.markdown-body .highlight{margin-bottom:16px}.markdown-body .highlight pre{margin-bottom:0;word-break:normal}.markdown-body .highlight pre,.markdown-body pre{padding:16px;overflow:auto;font-size:85%;line-height:1.45;background-color:#f6f8fa;border-radius:3px}.markdown-body pre code{display:inline;max-width:auto;padding:0;margin:0;overflow:visible;line-height:inherit;word-wrap:normal;background-color:transparent;border:0}.markdown-body pre code::after,.markdown-body pre code::before{content:normal}.markdown-body .full-commit .btn-outline:not(:disabled):hover{color:#005cc5;border-color:#005cc5}.markdown-body kbd{display:inline-block;padding:3px 5px;font:11px SFMono-Regular,Consolas,"Liberation Mono",Menlo,Courier,monospace;line-height:10px;color:#444d56;vertical-align:middle;background-color:#fafbfc;border:solid 1px #d1d5da;border-bottom-color:#c6cbd1;border-radius:3px;box-shadow:inset 0 -1px 0 #c6cbd1}.markdown-body :checked+.radio-label{position:relative;z-index:1;border-color:#0366d6}.markdown-body .task-list-item{list-style-type:none}.markdown-body .task-list-item+.task-list-item{margin-top:3px}.markdown-body .task-list-item input{margin:0 .2em .25em -1.6em;vertical-align:middle}.markdown-body hr{border-bottom-color:#eee}
    </style>
  </template>
</dom-module>
  • src/my-home.html <my-home>タグを表示するHTML
src/my-home.html
<link rel="import" href="../bower_components/polymer/polymer-element.html">
<script>
  class MyHome extends Polymer.Element {
    static get is() { return 'my-home' }
    static get template() { return `<h1>ようこそ!</h1>` }
  }
  customElements.define(MyHome.is, MyHome)
</script>
  • src/my-fire.html <my-fire>タグを表示するHTML
src/my-fire.html
<link rel="import" href="../bower_components/polymer/polymer-element.html">
<link rel="import" href="../bower_components/polymer/lib/elements/dom-if.html">
<link rel="import" href="../bower_components/polymerfire/firebase-app.html">
<link rel="import" href="../bower_components/polymerfire/firebase-query.html">
<link rel="import" href="../bower_components/polymerfire/firebase-document.html">
<link rel="import" href="../bower_components/polymerfire/firebase-auth.html">
<link rel="import" href="../bower_components/paper-icon-button/paper-icon-button.html">

<dom-module id="my-fire">
  <template>
    <style>
      paper-icon-button {
        --paper-icon-button-ink-color: white;
      }
    </style>

    <firebase-app
      api-key="AIzaSyAXoRzCFBT2eGSZCKnl5Rspz7Cg-cUvtU8"
      auth-domain="sample-2-8952f.firebaseapp.com"
      database-url="https://sample-2-8952f.firebaseio.com"></firebase-app>
    <firebase-query path="/data" data="{{pages}}"></firebase-query>
    <firebase-document path="/data/[[key]]" data="{{view}}"></firebase-document>
    <firebase-auth signed-in="{{login}}" provider="google"></firebase-auth>

    <paper-icon-button icon="my:[[_icon(login)]]" on-tap="_auth"></paper-icon-button>

  </template>
  <script>
    class MyFire extends Polymer.Element {
      static get is() { return 'my-fire' }
      static get properties() {
        return {
          key: {
            type: String
          },
          pages: {
            type: Array,
            notify: true
          },
          view: {
            type: Object,
            notify: true
          },
          login: {
            type: Boolean,
            notify: true
          }
        }
      }
      _icon(login){ return login ? 'logout' : 'google' }
      _auth(){
        const auth = this.shadowRoot.querySelector('firebase-auth')
        auth.signedIn ? auth.signOut() : auth.signInWithRedirect()
      }
    }
    customElements.define(MyFire.is, MyFire)
  </script>
</dom-module>
  • src/my-icons.html アイコン集ファイル
src/my-icons.html
<link rel="import" href="../bower_components/iron-icon/iron-icon.html">
<link rel="import" href="../bower_components/iron-iconset-svg/iron-iconset-svg.html">
<iron-iconset-svg size="24" name="my">
  <svg><defs>
    <g id="menu"><path d="M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z"/></g>
    <g id="close"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></g>
    <g id="delete"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></g>    
    <g id="search"><path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"></path></g>
    <g id="google"><path d="M21.35,11.1H12.18V13.83H18.69C18.36,17.64 15.19,19.27 12.19,19.27C8.36,19.27 5,16.25 5,12C5,7.9 8.2,4.73 12.2,4.73C15.29,4.73 17.1,6.7 17.1,6.7L19,4.72C19,4.72 16.56,2 12.1,2C6.42,2 2.03,6.8 2.03,12C2.03,17.05 6.16,22 12.25,22C17.6,22 21.5,18.33 21.5,12.91C21.5,11.76 21.35,11.1 21.35,11.1V11.1Z" /></g>
    <g id="home"><path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/></g>
    <g id="logout"><path d="M17,17.25V14H10V10H17V6.75L22.25,12L17,17.25M13,2A2,2 0 0,1 15,4V8H13V4H4V20H13V16H15V20A2,2 0 0,1 13,22H4A2,2 0 0,1 2,20V4A2,2 0 0,1 4,2H13Z" /></g>
  </defs></svg>
</iron-iconset-svg>
module.exports = {
  staticFileGlobs: [
    '/index.html',
    '/manifest.json',
    '/bower_components/webcomponentsjs/*.js'
  ],
  navigateFallback: 'index.html',
  navigateFallbackWhitelist: [/^(?!\/__)/]
};
  • manifest.json Webアプリケーションとしての設定ファイル
{
  "name": "Polymer Sample PWA",
  "short_name": "Polymer Smpl",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#fff",
  "theme_color": "#29549a",
  "icons": [{
    "src": "https://www.polymer-project.org/images/logos/p-logo-192.png",
    "sizes": "192x192",
    "type": "image/png"
  },{
    "src": "https://www.polymer-project.org/images/logos/p-logo-512.png",
    "sizes": "512x512",
    "type": "image/png"
  }]
}
  • bower.json Webコンポーネントライブラリの設定ファイル
{
  "name": "polymer-sample-pwa-2",
  "dependencies": {
    "polymer": "Polymer/polymer",
    "webcomponentsjs": "webcomponents/webcomponentsjs",
    "app-route": "PolymerElements/app-route",
    "app-layout": "PolymerElements/app-layout",
    "paper-ui-elements": "PolymerElements/paper-ui-elements",
    "paper-toggle-button": "PolymerElements/paper-toggle-button",
    "marked-element": "PolymerElements/marked-element",
    "prism-element": "PolymerElements/prism-element",
    "sanitize-element": "howking/sanitize-element",
    "polymerfire": "firebase/polymerfire"
  }
}
  • polymer.json Polymer CLIの設定ファイル
{
  "entrypoint": "index.html",
  "shell": "src/my-view.html",
  "fragments": [
    "src/my-marked.html"
  ],
  "extraDependencies": [
    "manifest.json",
    "bower_components/webcomponentsjs/*.js"
  ],
  "sources": [
    "src/**/*.html"
  ],
  "builds": [{
    "name": "default",
    "preset": "es5-bundled"
  }]
}
  • firebase.json Firebaseの設定ファイル
{
  "hosting": {
    "public": "build/default",
    "rewrites": [{
      "source": "!/__/**",
      "destination": "/index.html"
    }]
  }
}

各行の説明

前回説明した部分は省きます。

index.html

index.html
    <my-view>
      ...
    </my-view>
    <script src="/bower_components/webcomponentsjs/webcomponents-loader.js"></script>
    <link rel="import" href="/src/my-view.html">
    <script>
      if ('serviceWorker' in navigator) {
        window.addEventListener('load', function() {
          navigator.serviceWorker.register('/service-worker.js');
        });
      }
    </script>
  </body>

前回まではWeb Componentsのポリフィルやmy-viewタグ、ServiceWorkerの読み込みを<head>の中に入れていましたが、表示速度をチューニングしているPolymer HN等と同じように<body>の最後に入れています。

src/my-view.html

src/my-view.html
<link rel="import" href="../bower_components/app-route/app-location.html">
<link rel="import" href="../bower_components/app-route/app-route.html">
...
    <style>
      ...
      a paper-icon-button { text-decoration: none; color: white; }
      paper-listbox a { text-decoration: none; color: black; }
      paper-listbox a.iron-selected { color: lightgray; }
      ...
    </style>

    <app-location route="{{route}}"></app-location>
    <app-route route="{{route}}" pattern="/:key" data="{{routeData}}"></app-route>
        ...
            <a href="/"><paper-icon-button icon="my:home" drawer-toggle></paper-icon-button></a>
        ...
            <a href="/[[item.$key]]"><paper-item drawer-toggle>[[item.title]]</paper-item></a>

フレンドリーURLを使う為にapp-location、ルータ機能を使う為にapp-routeを読み込みます。
ページ遷移は<a>タグでリンクを指定するだけです。<app-route pattern"/:key">で定義された第一階層のパラメータが{{routeData.key}}として格納されます(view1view2など)。

ただ、Fetch as Googleではちゃんと表示してくれませんでした、、、
image.png

src/my-view.html
<link rel="import" href="my-home.html">
<link rel="import" href="my-icons.html">
<link rel="import" href="my-fire.html">
<link rel="import" href="my-marked.html" async>

<my-view>の見通しをよくする為にコンポーネント(タグ)を分けます。my-markedasyncで多少読み込みを遅らせています。

src/my-view.html
            <my-fire key="[[routeData.key]]" pages="{{pages}}" view="{{view}}" login="{{login}}"></my-fire>

<my-fire>タグはログインボタンを表示しつつ、データ取得やログイン状態を返すようにします。
keyで指定したページのコンテンツを{{view}}として戻す処理、{{pages}}にページのリンク先やタイトルを格納します。
また、Markdownの編集はログインした人のみに限定する為に、ログイン状態は{{login}}という真偽値(Boolean)に格納するようにします。

src/my-view.html
        <template is="dom-if" if="[[!routeData.key]]">
          <my-home></my-home>
        </template>

        <template is="dom-if" if="[[routeData.key]]">
          <my-marked contents="{{view.contents}}" login="[[login]]"></my-marked>
        </template>

URLが{{routeData}}に格納されているので、[[!routeData.key]]routeData.keyに値がない場合(=トップページ)を表し、<my-home>を表示させます。/view1などのページ指定があった場合には<my-marked>でMarkdownのデータを表示させます。

src/my-view.html
  <script>
    class MyView extends Polymer.Element {
      static get is() { return 'my-view' }
    }
    customElements.define(MyView.is, MyView)
  </script>

前回と比べてプロパティも必要なくなったので、タグを宣言するだけとなりました。

src/my-marked.html

src/my-marked.html
<link rel="import" href="../bower_components/prism-element/prism-highlighter.html">
<link rel="import" href="../bower_components/prism-element/prism-theme-default.html">
...
<!-- https://poly-style.appspot.com?id=github-markdown-styles&url=https://cdnjs.cloudflare.com/ajax/libs/github-markdown-css/2.8.0/github-markdown.min.css -->
<link rel="import" href="my-theme.html">
...
    <style include="prism-theme-default my-theme">
    ...
    <prism-highlighter></prism-highlighter>
    ...
      <main class="markdown-body" slot="markdown-html"></main>

prism-elementでソースの色付けをできるようにします。<style include="prism-theme-default">でスタイルシート情報を読み込んでおいてから、<prism-highlinghter>タグを書いておけば、marked-elementと連携して色付けしてくれます。

my-theme.htmlgithub-markdown.cssPolyStyleを使って出力した結果をファイルに保存したものです。<main class="markdown-body">でクラスを指定すると反映されます。

src/my-marked.html
<link rel="import" href="../bower_components/sanitize-element/sanitize-element.html">
...
    <sanitize-element
      sanitizer="{{_sanitizer}}"
      config='{"elements":["my-home","img"],
               "attributes":{"img":["class","src"]},
               "protocols":{"img":{"src":["https"]}}}'></sanitize-element>
...
    <marked-element sanitize sanitizer="[[_sanitizer]]" markdown="[[contents]]">

手前味噌のコンポーネント ですが、sanitize-elementでMarkdown上表示させたくないHTMLタグをサニタイズします(Sanitize.jsを利用するだけのもの)。
<marked-element>sanitize sanitizer="[[_sanitizer]]"を指定し、<sanitize-element>で使用したいタグや属性をconfig属性にホワイトリスト形式で指定します。
サンプルでは<img>タグと<my-home>タグが使えるようにしています。

src/my-marked.html
    <template is="dom-if" if="[[contents]]">
      <template is="dom-if" if="[[login]]">
        <paper-toggle-button checked="{{_edit}}"></paper-toggle-button>
        <paper-textarea value="{{contents}}" hidden="[[!_edit]]"></paper-textarea>
      </template>
    </template>

編集用のトグルボタンは内容([[contents]])が存在していて、ログイン([[login]])されていればテキストエリアとともに表示させます。
<paper-toggle-button>はONになるとchecked属性が自動で付与され、また<paper-textarea>hidden属性をONにすると表示が消えるので、{{_edit}}でつなげるだけで表示の切替ができて便利です。

src/my-home.html

src/my-home.html
<link rel="import" href="../bower_components/polymer/polymer-element.html">
<script>
  class MyHome extends Polymer.Element {
    static get is() { return 'my-home' }
    static get template() { return `<h1>ようこそ!</h1>` }
  }
  customElements.define(MyHome.is, MyHome)
</script>

「ようこそ!」と表示するだけのコンポーネントですが、なんということでしょう、、、<dom-module>がありません。 元々使えたstatic get template()ですが、先日(2017/08/22)のPolymerSummit 2017では今後のPolymer3.0に向けてBower、HTML Importsを廃止してNPM、ES6 modulesに移行するそうで、template関数側にHTMLを書くようになりそうです。

src/my-icons.html

src/my-icons.html
<link rel="import" href="../bower_components/iron-icon/iron-icon.html">
<link rel="import" href="../bower_components/iron-iconset-svg/iron-iconset-svg.html">
<iron-iconset-svg size="24" name="my">
  <svg><defs>
    ...
    <g id="google"><path d="M21.35,11.1H12.18V13.83H18.69C18.36,17.64 15.19,19.27 12.19,19.27C8.36,19.27 5,16.25 5,12C5,7.9 8.2,4.73 12.2,4.73C15.29,4.73 17.1,6.7 17.1,6.7L19,4.72C19,4.72 16.56,2 12.1,2C6.42,2 2.03,6.8 2.03,12C2.03,17.05 6.16,22 12.25,22C17.6,22 21.5,18.33 21.5,12.91C21.5,11.76 21.35,11.1 21.35,11.1V11.1Z" /></g>
    ...
  </defs></svg>
</iron-iconset-svg>

前回までは<iron-icons>から適当なアイコンを選んで使っていましたが、使っていないデータも読み込むのは無駄なので、使うものだけ選んで定義しておきます。
Polymer Iconset Generatorからポチポチ選ぶと楽ですが、Material Design Icons等からSVGを貼り付けても使えます(View SVGで表示しつつfill="#000000"などは削る必要があります)。

src/my-fire.html

src/my-fire.html
    <firebase-query path="/data" data="{{pages}}"></firebase-query>
    <firebase-document path="/data/[[key]]" data="{{view}}"></firebase-document>

<firebase-query>でキーやタイトル情報を一式取得し、<firebase-document>[[key]]から指定されたMarkdownのテキスト情報を取得します。

そもそも<firebase-query>で全てのデータを取得してしまっているので、<firebase-document>で個別に指定する必要はないのですが、データが増えていく場合は、毎回全部データを取得するのは重い処理になってしまうので、キーやタイトルだけ別のツリーにするなどフラットなデータ構成に変更する必要があります。

src/my-fire.html
    <firebase-auth signed-in="{{login}}" provider="google"></firebase-auth>

    <paper-icon-button icon="my:[[_icon(login)]]" on-tap="_auth"></paper-icon-button>
    ...
    <script>
      ...
      _icon(login){ return login ? 'logout' : 'google' }
      _auth(){
        const auth = this.shadowRoot.querySelector('firebase-auth')
        auth.signedIn ? auth.signOut() : auth.signInWithRedirect()
      }

ユーザ認証用のボタンなど、自分で一からつくるのは気が遠くなりますが、FirebaseとPolymerで簡潔に書けます(エラー処理とかはしてないですが)。
ただし、現状PolymerfireとPolymer CLIの組み合わせは悪く、ビルド時に修正を加えないと動きません 。IEやEdgeの動きがおかしいのもこのせいな気がしますが、Polymer/polymer-cli#701が早く直ることを切に願うばかりです。

無理矢理動かすPerlスクリプト
$ perl -pi -e 's/ks\(this\);var lI=this.Hd;if\(lI\)for\(var uI=\[\],hI=1;/ks(this);var uI=[];var lI=this.Hd;if(lI)for(var hI=1;/' build/default/src/my-view.html

firebase.json

firebase.json
    "rewrites": [{
      "source": "!/__/**",
      "destination": "/index.html"
    }]

firebaseではホスティングのパスで/__/が認証等のライブラリ用に予約されているので、そこをはずしつつフレンドリーURLを使う為にリライトの設定をします。

sw-precache-config.js

sw-precache-config.js
  navigateFallbackWhitelist: [/^(?!\/__)/]

こちらも認証時のURL /__/以下でServiceWorkerが応答してしまわないように設定をしておきます。

最後に

:bow: コメント、編集リクエスト歓迎

サンプルコード(1) Hello World! から今回のサンプルコード (5) PWA [2] までで、ざっくりと Polymer の良さを知ってもらえればです!

以上

5
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
1