GatsbyプロジェクトをAMP対応する

# はじめに
こんにちは。オリンピック、サッカー準決勝のチケットを持っているのですが、観客人数制限に伴い、再抽選するそうで、複雑な気持ちです。いっそ外れてしまえば、行く行かないで迷わないで済みそうですよね :thinking: 筆者です。

さて、今回はGatsbyプロジェクトをAMPに対応させるために行ったことを記事にしよう思います :+1:
参考になれば幸いです！

# 方針
1. `gatsby build`で通常のHTML作成.
1. 1をAMP用に変換.
1. publicに配置.

# 参考にさせていただいた記事

https://panda-program.com/posts/gatsby-amp

# GatsbyプロジェクトをAMP対応する
上記記事を参考に、自分のプロジェクトに合わせていろいろいじくりました。
それを記していきます！

## 1. 必要なnode modulesをインストールする
参考にさせていただいた記事では、`recursive-readdir`を使用していましたが、後続でエラーが出るので、今回は`glob`を使用しています。

また、`ampifyjs`をnpmでinstallして使用はしません。
理由は、後続で説明しますが、ざっくり私の場合都合が悪かったためです。

```sh
$ npm install glob node-sass cheerio
```

## 2. ampify.jsをコピーする
以下を一旦プロジェクトルートに配置します。

https://github.com/chiedo/gatsby-amp-starter-blog/blob/master/ampify.js

以下コマンドで取ってこれます :ok_hand:

```sh
$ wget https://raw.githubusercontent.com/chiedo/gatsby-amp-starter-blog/master/ampify.js
```

## 3. node modulesの`ampifyjs`を2のコードに取り込む

node modulesのampyfyjsのコードはこちらです↓

https://github.com/chiedo/ampify/blob/master/index.js

取り込むとこんな感じです↓

```js
'use strict';

const recursive = require('recursive-readdir');
const fs = require('fs');
const ampify        = require('ampifyjs');

const GA_TRACKING_ID = 'UA-SOME_ANALYTICS_ID-1';
const sass    = require('node-sass');

//  The director that we will be creating an amp verion of.
//  Creating an amp version ultimately means creating an 'amp'
//  directory in that with amp versions of each file from the source.
const inputDir = 'public/amp';

// This is where we will populate the last of files to convert
let filesToConvert = [];

// Get a list of all the files in the public directory. But ignore the amp dir
recursive(inputDir, [], (err, files) => {
  // Files is an array of file paths. Lets just get the html files
  for (let file of files) {
    // Only select files that end in '.html'.
    if(file.endsWith('.html')) {
      filesToConvert.push(file);
    }
  }

  // For each file, modify it to add the amp page reference and then create the amp
  // version
  for(let fileToConvert of filesToConvert) {
    // Add the amp url link to the top of the page then Save the file
    fs.writeFileSync(fileToConvert, (() => {
      const baseUrl = fileToConvert.replace(inputDir, ''); // No inputDir in the URL
      const html = fs.readFileSync(fileToConvert, 'utf8');

      // The tags that we will convert to amp versions
      const tags = {
        amp: ['img', 'video', 'iframe'],
      };

      // Load the html so we can manipulate it with jQuery syntax on the server
      const $ = cheerio.load(html, {
        normalizeWhitespace: false,
        xmlMode: false,
        decodeEntities: false,
        cwd: '',
        round: true,
      });

      /**************************************************************************************
       * GROUNDWORK
       *************************************************************************************/
      //remove all script tags. If any specific script tags are needed
      //they can be added back later. This gives us a clean slate though
      $('script').each(function() {
        // Dont remove structured data though
        if($(this).attr('type') !== 'application/ld+json'){
          $(this).remove();
        }
      });


      // Remove google back analytics
      $('script[custom-element="amp-analytics"]').remove();

      // Add AMP HTML5 Video Support
      if($('body video').length > 0) {
        $('script[custom-element="amp-video"]').remove();
        $('head').append('<script async custom-element="amp-video" src="https://cdn.ampproject.org/v0/amp-video-0.1.js"></script>');
      }

      // Add AMP Iframe Support
      if($('body iframe').length > 0) {
        $('script[custom-element="amp-iframe"]').remove();
        $('head').append('<script async custom-element="amp-iframe" src="https://cdn.ampproject.org/v0/amp-iframe-0.1.js"></script>');
      }

      // Remove existing noscripts and styles
      $('noscript').remove();
      $('style').remove();

      //Add the amp attribute to the html
      $('html').attr('amp', '');

      //Add the required meta charset tags if not already present
      if ($('head meta[charset="utf-8"]').length === 0) {
        $('head').append('<meta charset="utf-8">');
      }

      // The amp version of the site should not have any amphtml.
      // We'll set the correct canonical link later
      $('head').find('link[rel="amphtml"]').remove();
      $('head').find('link[rel="canonical"]').remove();

      // Remove preloader code
      $('head').find('link[as="script"]').remove();

      // Add the canonical link to the URL as specified by the AMP standards
      $('head').append(`<link rel="canonical" href="${baseUrl}" />`);

      // If the viewport meta isn't correctly set in regards to the amp standards, then set it
      if ($('head meta[content="width=device-width,minimum-scale=1,initial-scale=1"]').length === 0) {
        // Remove the viewport meta if it exists
        $('head meta[name="viewport"]').remove();
        // Add the correct viewport meta
        $('head').prepend('<meta name="viewport" content="width=device-width,minimum-scale=1,initial-scale=1">');
      }

      // add the main amp library
      if ($('head script[src="https://cdn.ampproject.org/v0.js"]').length === 0) {
        $('head').append('<script async src="https://cdn.ampproject.org/v0.js"></script>');
      }


      $('head').prepend('<style amp-boilerplate>body{-webkit-animation:-amp-start 8s steps(1,end) 0s 1 normal both;-moz-animation:-amp-start 8s steps(1,end) 0s 1 normal both;-ms-animation:-amp-start 8s steps(1,end) 0s 1 normal both;animation:-amp-start 8s steps(1,end) 0s 1 normal both}@-webkit-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-moz-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-ms-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-o-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}</style><noscript><style amp-boilerplate>body{-webkit-animation:none;-moz-animation:none;-ms-animation:none;animation:none}</style></noscript>');

      // Remove any styles we have that weren't added the 'amp' way
      $('link[rel=stylesheet]').each(function() {
        $(this).remove();
      });

      // remove style attributes from everything. No inline styles with amp
      $( '*' ).removeAttr('style');


      /**************************************************************************************
       * AMP CONVERSIONS
       *************************************************************************************/
      // convert img tags to amp-img tags and video tags to amp-video tags, etc.
      $(tags.amp.join(',')).each(function() {
        this.name = 'amp-' + this.name;
      });

      // Set the layouts for all the images
      $('.main-pane amp-img, .page amp-img').each(function(){
        if($(this).attr('data-layout')) {
          $(this).attr('layout', $(this).attr('data-layout'));
        } else {
          // For images that are really large, let them be responsive and allow them to go full screen
          // Fixed images that are really large don't scale down well with AMP for some reason. So this
          // is somewhat of a hack fix
          if($(this).attr('width') > 700 ) {
            $(this).attr('layout', 'responsive');
          // For most images, just let them be fixed and css will scale them down
          } else {
            $(this).attr('layout', 'fixed');
          }
        }
      });

      /**************************************************************************************
       * Replace certain elements on the page for AMP specifically
       *************************************************************************************/
      extras($);

      /**************************************************************************************
       * Replace certain elements on the page for AMP specifically
       *************************************************************************************/
      $('amp-iframe').attr('sandbox','allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox');
      $('amp-iframe').attr('layout','responsive');
      $('amp-video').attr('layout', 'responsive');
      $('amp-video').attr('height', '270');
      $('amp-video').attr('width', '480');

      // Google Analytics
      $('head').append('<script async custom-element="amp-analytics" src="https://cdn.ampproject.org/v0/amp-analytics-0.1.js"></script>');
      $('amp-analytics').remove();
      $('body').prepend('<amp-analytics type="googleanalytics" id="analytics1">\
        <script type="application/json">{"vars": {"account": "'+GA_TRACKING_ID+'"},"triggers": {"trackPageview": {"on": "visible","request": "pageview"}}}</script>\
        </amp-analytics>');

      /**************************************************************************************
       * STYLES
       *************************************************************************************/
      // We are using Sass so we need to get each of the styles we need for the amp version of the pages
      // and compile it to minified sass.
      let css;
      css = sass.renderSync({file: 'css/zenburn.css', outputStyle: 'compressed'}).css.toString() +
            sass.renderSync({ file: 'css/amponly.scss', outputStyle: 'compressed'}).css.toString();

      // Remove all important tags since they are not permitted in amp styles
      css = css.replace(/!important/g, '');

      // Add our new style to the head as required my amp
      $('head').prepend(`<style amp-custom>${css}</style>`);

      /**************************************************************************************
       * DONE
       *************************************************************************************/
      return $.html();
    })()
  }
  console.log('The site is now AMP ready');
});
```

## 4. ampify.jsを調整します

調整する点は以下の通りです。
1. 使用するnode modulesのimport文追加と不要なimport文削除
2. 1に伴いコードの変更
3. amp-imgタグにwidthとheightを設定
4. pictureタグをdivタグに書き換える
5. CSSの読み込み方法を変更

### 4.1. 使用するnode modulesのimport文追加と不要なimport文削除

```js:ampify.js
- const recursive = require('recursive-readdir');
+ const glob = require('glob')
  const fs = require('fs');
- const ampify = require('ampifyjs');
+ const cheerio = require('cheerio');
```

### 4.2. 4.1に伴いコードの変更
`recursive-readdir`使用箇所を`glob`に変更します。
`glob`にしたことで従来に比べコード量が少なくすることができました :+1:

```js:ampify.js
// Get a list of all the files in the public directory. But ignore the amp dir
- recursive(inputDir, [], (err, files) => {
-   // Files is an array of file paths. Lets just get the html files
-   for (let file of files) {
-     // Only select files that end in '.html'.
-     if(file.endsWith('.html')) {
-       filesToConvert.push(file);
-     }
-   }
+ glob("public/amp/**/*.html", (e, filesToConvert) => {

    // For each file, modify it to add the amp page reference and then create the amp
    // version
    for(let fileToConvert of filesToConvert) {
      // Add the amp url link to the top of the page then Save the file
      fs.writeFileSync(fileToConvert, (() => {


// ...
// ...
// ...
}
```

### 4.3 amp-imgタグにwidthとheightを設定
こちらは参考にさせていただいた記事と同様のことを行います。

```js:ampify.js
      fs.writeFileSync(fileToConvert, (() => {
        const baseUrl = fileToConvert.replace(inputDir, ''); // No inputDir in the URL
        const html = fs.readFileSync(fileToConvert, 'utf8');
  
        // ...
        // ...
        // ...

+       $('amp-img').attr('layout', 'responsive');
+       $('amp-img').attr('width', '450');
+       $('amp-img').attr('height', '270');
+       $('amp-img').removeAttr('loading');

        /**************************************************************************************
         * DONE
         *************************************************************************************/
        return $.html();
      })()
```

### 4.4. pictureタグをdivタグに書き換える
こちらも参考にさせていただいた記事と同様のことを行います。

```js:ampify.js
      fs.writeFileSync(fileToConvert, (() => {
        const baseUrl = fileToConvert.replace(inputDir, ''); // No inputDir in the URL
        const html = fs.readFileSync(fileToConvert, 'utf8');

        // ...
        // ...
        // ...

+       const pictures = $('picture');
+       if (pictures.length > 0) {
+         pictures.children('source').remove();
+         pictures.each((_, element) => {
+           const ampImg = $(element).html().trim();
+           $(element).replaceWith(ampImg);
+         });
+       }

        /**************************************************************************************
         * DONE
         *************************************************************************************/
        return $.html();
      })()
```

### 4.5. CSSの読み込み方法を変更
こちらは参考にさせていただいた記事をベースに各種変更を加えています。

変更点は以下の通りです。

1. 外部cssの取得
2. inline埋め込みcssの取得
3. node modulesのampify.jsで行っているcss関連の処理を調整

#### 4.5.1. 外部cssの取得
`public/webpack.stats.json`からcssのパスを取得します。
参考にさせていただいた記事と異なるオブジェクト構造でしたので、各自確認が必要だと思います。
自分は以下の感じでした。

```js:ampify.js
        fs.writeFileSync(fileToConvert, (() => {
          // ...
          // ...
          // ...

           /**************************************************************************************
            * STYLES
           *************************************************************************************/
           // We are using Sass so we need to get each of the styles we need for the amp version of the   pages
           // and compile it to minified sass.
-           let css;
-           css = sass.renderSync({file: 'css/zenburn.css', outputStyle: 'compressed'}).css.toString() +
-                 sass.renderSync({ file: 'css/amponly.scss', outputStyle: 'compressed'}).css.toString();

+         const webpackStats = JSON.parse(
+           fs.readFileSync('public/webpack.stats.json')
+         );
+         
+         const files = webpackStats.namedChunkGroups['component---src-templates-page-js'].assets
+           .filter(f => f.name.includes('.css'));
+         let css = files.map((file) => sass.renderSync({
+           file: `public/${file.name}`,
+           outputStyle: 'compressed'
+         }).css.toString()).join('');

          // Remove all important tags since they are not permitted in amp styles
          css = css.replace(/!important/g, '');
    
          // Add our new style to the head as required my amp
          $('head').prepend(`<style amp-custom>${css}</style>`);

          /**************************************************************************************
           * DONE
           *************************************************************************************/
          return $.html();
        })()
```

#### 4.5.2. inline埋め込みcssの取得外部cssの取得
私の場合、上記外部cssとは別でheadタグ内にinlineでグローバルのcssを含んでいたため、それを取り出す必要がありました。

また、**node modulesのampify.js**内で先にすべての**styleタグ**を削除しているので、その処理を無効化する必要があります。

```js:ampify.js
        // Remove existing noscripts and styles
        $('noscript').remove();
-       $('style').remove();
  
        //Add the amp attribute to the html
        $('html').attr('amp', '');
```

そのあと、inlineのcssをすべて取得し**変数css**に付け足していきます。
このとき、**node modulesのampify.js**でも処理しているように、AMPで使用できない`!important;`を取り除く処理も行っています。

```js:ampify.js
        fs.writeFileSync(fileToConvert, (() => {
          /**************************************************************************************
           * STYLES
          *************************************************************************************/
          // We are using Sass so we need to get each of the styles we need for the amp version of the   pages
          // and compile it to minified sass.

          // ...
          // ...
          // ...

+         $('style').each((i, e) => {
+           css += $(e).html()
+         })

          // Remove all important tags since they are not permitted in amp styles
          css = css.replace(/!important/g, '');

          // Add our new style to the head as required my amp
          $('head').prepend(`<style amp-custom>${css}</style>`);

          /**************************************************************************************
           * DONE
           *************************************************************************************/
          return $.html();
        })()
```

#### 4.5.3. node modulesのampify.jsで行っているcss関連の処理を調整
4.5.2で無効化した不要なstyleタグの削除を行う必要があるのでそのコードをコピペします。
また、`amp-boilerplate`のコードもコピペして下の方に持ってきます。
上の方に書いてあった`amp-boilerplate`は削除してください。

```js:ampify.js
        fs.writeFileSync(fileToConvert, (() => {
          /**************************************************************************************
           * STYLES
          *************************************************************************************/
          // We are using Sass so we need to get each of the styles we need for the amp version of the   pages
          // and compile it to minified sass.

          // ...
          // ...
          // ...

          $('style').each((i, e) => {
            css += $(e).html()
          })

          // Remove all important tags since they are not permitted in amp styles
          css = css.replace(/!important/g, '');

+         $('style').remove();

          // Add our new style to the head as required my amp
          $('head').prepend(`<style amp-custom>${css}</style>`);

+         $('head').prepend('<style amp-boilerplate>body{-webkit-animation:-amp-start 8s steps(1,end) 0s 1 normal both;-moz-animation:-amp-start 8s steps(1,end) 0s 1 normal both;-ms-animation:-amp-start 8s steps(1,end) 0s 1 normal both;animation:-amp-start 8s steps(1,end) 0s 1 normal both}@-webkit-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-moz-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-ms-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-o-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}</style><noscript><style amp-boilerplate>body{-webkit-animation:none;-moz-animation:none;-ms-animation:none;animation:none}</style></noscript>');


          /**************************************************************************************
           * DONE
           *************************************************************************************/
          return $.html();
        })()
```

## 5. ビルドコマンドを作成する
ビルドする際に処理が多いのでビルド用のshellスクリプトを作成します。

全体の流れは以下の通りです。

1. gatsby buildしてstaticなファイルを生成する
2. 1ををコピーする
3. 2からAMPにしたいHTML以外削除する
4. ampify.jsを走らせる

自分は以下のようになりました。

```sh:build.sh
#!/bin/sh

# ①
# build
gatsby build

# ②
# amp
rm -rf public/amp
cp -r public amp
mv amp public
cd public/amp

# ③
# delete files exclude .html
find ./ -type f ! -name "*.html" -delete

# delete empty folders
find ./ -type d -empty -delete

# delete not support amp folders
ls -d */ | grep -P "^\d+\/$" | xargs rm -rfv

# delete other unuse files and folders
rm -rf tag/ offline-plugin-app-shell-fallback/ privacy-policy/ 404.html index.html

cd ../../

# ④
# convert html to amp
node ampify.js
```

## 6. package.jsonのscriptsを変更

```json:package.json
{
  "scripts": {
    "build": "./build.sh"
  }
}
```

## 7. ホスティング先のビルドコマンドの設定を変更
今まで、 `gatsby build`にしていた人は `npm run build`に変更してください！

# おわりに
かなりこねくり回しましたが、これで私のGatsbyプロジェクトはAMP対応しました :v:

AMPにしてみたい方ぜひこの機会にいかがでしょうか :smile: