18
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

NIJIBOXAdvent Calendar 2020

Day 10

PugとScssをまとめなさいと天命を受けたのでwebpackプラグインを生み出した

Last updated at Posted at 2020-12-09

こんにちは。NIJIBOXのエンジニアのつんあーです。

最近は社内で"あーさん"と呼んでいただいているので、
そろそろQiitaの表示名も直した方がいいのかなと思っています。

去年のアドカレは全面的にネタに振っていたのですが、
今年は割と正直に成果物で勝負をかけてみる方針にしました。

力こそパワー。

本日のお品書き

前々から「あったらいいなぁ」と思っていたScss in Pugなwebpackプラグインを作ってみたので、ご紹介いたします。

  • pug-stylekit-webpack-pluginのご紹介
    • 概要
    • 使い方
    • モチベーション
    • あんなこといいな、できたらいいな
  • おまけ:webpackプラグインの作り方と、pug-stylekit-webpack-pluginのコード解説

自分的には絶対ウケると思って*「雨が夜明け過ぎに雪へと変わるChrome拡張」を作ろうとしていたのですが、
構想を知人に壁打ちしたところ2秒で
**「何に使うのソレ」***とFBをいただいたため潔く却下した次第です。

「無駄や失敗にまみれた不本意な毎日こそ、人生を形作っているものだと思う」
by 山田ルイ53世

私の人生を形作っているものは、しょうもなさそうなものから再び価値を見出すことへの興味でできているのかもしれません。
ルネサンスですね。

違いますね。

pug-stylekit-webpack-pluginのご紹介

概要

最新バージョン:0.8.11
→まだまだ機能を充実させる余地があるのと、十分なテストもしていないのでバージョンは低めです。

GitHub:https://github.com/ats05/pug-stylekit-webpack-plugin
npm:https://www.npmjs.com/package/pug-stylekit-webpack-plugin

pug-stylekit-webpack-pluginは、Pugファイルの中に直接Scss(Sass)を書き込み、ビルドできるプラグインです。

Pugをhtmlファイルにコンパイルしつつ、
Pug中に挿入されたstylekitブロックをまとめてcssファイルとして、
それぞれビルドディレクトリ内に書き出すことができます。

下記のサンプルの//- stylekitというブロックコメントが、
pug-stylekit-webpack-pluginで認識できるstylekitブロックです。


▼インプット:index.pug

index.pug
doctype html
html(lang='ja')
    head
        meta(charset="utf-8")
        link(rel="stylesheet" href="./style.css")
    body.body
        h1.body__title Hello, this is the pug-stylekit-webpack-plugin!
            //- stylekit
                .body {
                    &__title { 
                        color: #FF0000;
                    }
                }

▼アウトプット:index.html

index.html
<!DOCTYPE html>
<html lang="ja">
    <head>
        <meta charset="utf-8">
        <link rel="stylesheet" href="./style.css">
    </head>
    <body class="body">
        <h1 class="body__title">Hello, this is the pug-stylekit-webpack-plugin!</h1>
    </body>
</html>

(↑見やすいように整形しています)

▼アウトプット:style.css

style.css
.body__title {
    color: #FF0000;
}

使い方

すでにnpmで公開済みなので、下記コマンドでインストールができます。

npm i --save-dev pug-stylekit-webpack-plugin

webpackの設定

ミニマム設定のサンプルは以下の通りです。
オプションを指定してpluginsに入れてあげるだけでOKです。

webpack.config.js
const path = require('path');

const SOURCE_DIR = path.resolve(__dirname, 'src');
const OUTPUT_DIR = path.resolve(__dirname, 'dist');

const PugStyleKitWebpackPlugin = require('pug-stylekit-webpack-plugin');

module.exports = {
    entry: SOURCE_DIR + '/entry.js',
    output: {
        filename: 'build.js',
        path: OUTPUT_DIR
    },
    module: {},
    plugins: [
        new PugStyleKitWebpackPlugin({
            target: {
                from: SOURCE_DIR + '/index.pug',
                to: {
                    html: OUTPUT_DIR + '/index.html',
                    css: OUTPUT_DIR + '/style.css',
                },
            }
        }),
    ]
};

オプションの書き方

▼基本的な書き方はこんな感じ。

  • target.from:書き出し対象のPugファイル
  • target.to.html:htmlファイルの書き出し先
  • target.to.css:cssファイルの書き出し先

ex)

webpack.config.js
...
new PugStyleKitWebpackPlugin({
    target: {
        from: SOURCE_DIR + '/index.pug',
        to: {
            html: OUTPUT_DIR + '/index.html',
            css: OUTPUT_DIR + '/style.css',
        },
    }
}),
...

targetオプションに配列を渡すことで、複数ファイルのビルドが可能です。

ex)

webpack.config.js
...
new PugStyleKitWebpackPlugin({
    target: [
        {
           from: SOURCE_DIR + '/index.pug',
            to: {
                html: OUTPUT_DIR + '/index.html',
                css: OUTPUT_DIR + '/style.css',
            },
        },
        {
           from: SOURCE_DIR + '/about/index.pug',
            to: {
                html: OUTPUT_DIR + '/about/index.html',
                css: OUTPUT_DIR + '/about/style.css',
            },
        }
    ],
}),
...

target.to.cssオプションを省略すると、cssファイルは生成せず、単なるPugのコンパイラとして機能します。

ex)

webpack.config.js
...
new PugStylekitWebpackPlugin({
    target: {
        from: SOURCE_DIR + '/index.pug',
        to: {
            html: OUTPUT_DIR + '/index.html',
        },
    }
}),
...

コードの書き方

stylekitというラベルをつけたブロックコメントの中に、Scssのコードを記述します。
このコードはPugではブロックコメントとして認識されるため、htmlには出力されません。

stylekitブロックの内部では、基本的にScss(Sass)の構文がすべて使えます。

Pugファイルでinclude、extends等を行った場合、
include先のPugファイルに含まれるstylekitブロックも、include元のcssにまとめて書き出されます。

index.pug
.block
    include ./_parts.pug 
    p.block__element ほげほげ
    //- stylekit
        .block {
            &__element {
                color: #FF0000;
            }
        }
_parts.pug
.parts
    .parts__child

//- stylekit
    .parts {
        &__child {
            color: #00FF00;
        }
    }

▼出力結果

style.css

.parts__child {
    color: #00FF00;
}

.block__element {
    color: #FF0000;
}

モチベーション

最近は業務でフロントエンド領域を触ることが増えてきており、
コーディング環境の初期構築を行ったりすることも多々あります。

ぶっちゃけ、PugとScssでディレクトリ構造を合わせるの、結構しんどくないでしょうか?
モジュール化の粒度が違ったり、微妙にネーミングが変わったりしてくると一気にメンテナンス性が落ちてくる気がします。

知見者「ああ、Pugは_navigation.pugだけどScssは_sidebar.scssだからね」

ex)

.
└── src
    ├── pug
    │   ├── index.pug
    │   └── modules
    │       └── _navigation.pug
    └── scss
        ├── index.scss
        └── module
            └── _sidebar.scss

まあPugの分割とScssの分割が必ずしも一致するわけではないので、
世界のコーダーに「ディレクトリ構造統一せんかい!」と言えるわけではないのですが、
さほど大規模でないサイト制作の中で、ムダにファイル数を増やしすぎるもの何だかなぁと思うわけです。

特にBEMをとても厳密に考えると、「blockごとにScssファイルを分けなさい」というルールがあったりしますしね。

今まで何度もPugファイルの中に直接styleタグを書き込むという悪魔の閃きと心の中で戦ってきたのですが、
「いっそのことちゃんと動くようにして作っちゃえばいいんじゃね!?」ってことで、着手した次第です。

Reactのstyled-componentsなどと同じようなことができればいいなぁ、というような感じです。


あんなこといいな、できたらいいな

@use、@importの相対パス化

Pugファイルをモジュールとして分割していったときに、
分割したモジュールから@import@useを使う可能性ってもちろんありますよね。

現状だと、target.fromに指定したPugファイルからの相対パスのみ解決できるようになっています。
が、各Pugモジュールから相対パスで取得できた方が見通しがいいと思っています。

dart-sassのincludePathsオプションとかでいけるかな?

cssファイルの統合

現状、targetに20個のPugファイルを渡すと、20個のcssファイルが出力されます。

画面ごとにcssが別になるとブラウザキャッシュが効きにくくなるので、
規模によってはサイト全体の閲覧パフォーマンスが落ちてしまう場合があります。

そのため、cssをまとめる機能があってもいいかなと思っています。

できたらいいな.js
...
new PugStyleKitWebpackPlugin({
    target: [
        {
            group: {
                outcss: OUTPUT_DIR + '/style.css',
                pugs: [
                    { from: SOURCE_DIR + '/index.pug', to: OUTPUT_DIR + '/index.html' },
                    { from: SOURCE_DIR + '/about.pug', to: OUTPUT_DIR + '/about.html' },
                    { from: SOURCE_DIR + '/contact.pug', to: OUTPUT_DIR + '/contact.html' }
                },
            }
        }
    ],
}),
...

ただ、これを解消しようとすると、cssの完全に重複する部分をマージしつつ、
意図的に上書きしている部分は残すような作りを考える必要が出てきます。

テンプレートファイル的な書き方になるのかなぁ。

Pug、Scssのコンパイルオプション

PugやScssは、コンパイル時に様々なオプションを渡せますね。
インデントを潰したりのオプションを渡せるようにしたいのですが、どこまで自由度を持たせようかなぁ、というところが悩みどころ。

やっぱり、minifyくらいは必要だよね。

ソースマップの有効化

現状、Scssのソースマップをオフにしています。
これをオンにしつつ(これ自体はdart-sassにオプション渡すだけ)、
cssからどのPugファイルに書いてあるのかを辿れたら、デバッグしやすいなと思っています。

stylus対応

あったほうがいいよね。

各種テスト

現状、ScssとおそらくSassも動きます。まだ試していないです。
簡単にPugのextendsやmixinも試していて、一応動くのですが、こちらももっと厳密にテストしてみた方がいいと思っています。


せっかく作ったので、こっそりといろいろなところで使ってみようと思っています。
その中で課題を見つけたり、アップデートをしたりとメンテナンスしていこうと思っています。

だんだん便利にできればと思うので、どうかお見守りいただけたら幸いです。

そして、PRやissueも、お待ちしています。
私自身、OSSコミュニティに属したりといったことはほぼなく、
OSSコミュニティのルールみたいなものもあまり意識したことがありません。

ゆるりとやっていこうと思っています。

「OSSなんてむりむり!」な方の練習台になったらいいな、くらいの想いです。

(おまけ)webpackプラグインの作り方と、pug-stylekit-webpack-pluginのコード解説

今回初めてwebpackのプラグインを作ってみたのですが、
いろいろと勉強になりました。
実は探してみるとplugin周りのドキュメントってあまり多くないのですね。
なので、実際のところは公式リファレンスや、公式のプラグインの実装を参考にしながら作ったりしました。
(私は正直、始めるまではloaderとpluginの違いもよくわかっていなかったです)

もしwebpackプラグインを作ってみたい、と考えている方がいれば、参考にしてみてください。


loaderとpluginを私の言葉で説明すると、こんな感じ。

  • loader:.js以外のファイルを、jsにバンドルするための変換器(webpackの本来の目的ですね)
  • plugin:webpackのプロセス全体にアクセスできるプログラム。もちろんwebpackと全然関係ないこともできる。嫌いなアイツのコンパイル時間を256倍にすることだってできちゃう

pug-stylekit-webpack-pluginでは、

  1. webpackがコンパイルを行うタイミングをフックし、処理開始
  2. Pugファイルを読み込んでhtmlにコンパイル
  3. Pugファイルの中から特定の記述を切り出してつなげ、cssとしてコンパイル
  4. htmlとcssをwebpackの出力対象ファイルとして差し込む

という感じで、処理を行っています。


それでは、プラグインの本体となる下記のソースコードを見ていきましょう。
https://github.com/ats05/pug-stylekit-webpack-plugin/blob/main/index.js

webpackのリファレンスにも、Writing a Pluginとしてプラグインの作り方が説明されています。

まずは、骨組みを見ていきます。

プラグインの全体像

index.js
const schemaUnits = require('schema-utils');
const {Compilation, sources: {RawSource}} = require('webpack');
...省略...

const schema = {
...省略...
};

const PLUGIN_NAME = 'PugStylekitWebpackPlugin';

class PugStylekitWebpackPlugin {

  constructor(options = {}){
    schemaUnits.validate(schema, options, { name: PLUGIN_NAME });
    this.options = options;
  }

  apply(compiler) {
    compiler.hooks.compilation.tap(PLUGIN_NAME, compilation => {
    ...省略...
    });
  }


}

module.exports = PugStylekitWebpackPlugin;

こうしてみると結構シンプルですね。
省略したのは、私が自分で処理を組み立てた部分です。

リファレンスにはこんな風に書いてあります。

A plugin for webpack consists of:

  • A named JavaScript function or a JavaScript class.
  • Defines apply method in its prototype.
  • Specifies an event hook to tap into.

気合(Google翻訳)で訳すと、

  • 名前付きのJavaScript関数、もしくはクラスです。
  • applyプロトタイプメソッドを定義します。
  • 使用するイベントフックを指定します。

てな感じでしょうか。

要するに、最低この3要素を満たすように作れば良いのです。

これを踏まえて、上のコードから一部を抜粋します。

index.js
  apply(compiler) {
    compiler.hooks.compilation.tap(PLUGIN_NAME, compilation => {
    ...省略...
    });
  }

applyメソッドが定義されていて、引数がcompilerとなっています。

compilerは、webpackコマンドで実行されるjsのコンパイル処理のことを指しています。
(上手い説明ができず、スミマセン。「ほーん」くらいのテンションで聞き流してください)

詳しいことはこの辺に書いてあります。多分。

英語はフィーリングで読んでいます。アイキャンアンダースタンド。

compilerオブジェクトを使うと、webpackコマンドで実行されるコンパイル処理全体にアクセスできます。
コンパイル前に処理を挟んだり、コンパイル後に処理を挟んだりができます。

complierオブジェクトから参照できるフックはこちらにまとまっています(めっちゃたくさんあります)
https://webpack.js.org/api/compiler-hooks/

pug-stylekit-webpack-pluginでは、その中のcompilationフックを利用しています。

tapというメソッドについては↓に解説があるのですが、正直よくわかりませんでした。むつかしい。
https://webpack.js.org/api/plugins/#tapable
要するに、コンパイル処理になんらかのプログラムを差し込むためのInterfaceのようです。
同期的(tap)だったり、非同期(tapAsync)だったり、promiseで解決させるもの(tabPromise)だったりいろいろあるので、用途に合わせてってことですかね。

compiler.hooks.compilation.tapのコールバック関数の引数として、compilationオブジェクトがあります。
これはコンパイル処理のコンテキストのようなもので、コンパイル対象のファイル名やコンパイル結果の吐き出し場所などが格納されています。

省略していた箇所をもう少し、紐解いてみましょう。

index.js
  apply(compiler) {
    compiler.hooks.compilation.tap(PLUGIN_NAME, compilation => {
      compilation.hooks.processAssets.tap(
        {
          name: PLUGIN_NAME,
          stage: Compilation.PROCESS_ASSETS_STAGE_ADDITIONS,
        }, (assets) => {
          ...省略...
          compilation.emitAsset(distPath, new RawSource(html));
          ...省略...
        });
    });
  }

compilationオブジェクトにもたくさんのフックがあります。
https://webpack.js.org/api/compilation-hooks/

今回は、その中でprocessAssetsフックを利用します。
このフックは、コンパイルが完了しファイルを出力する段階に処理を差し込むことができます。

stage属性にCompilation.PROCESS_ASSETS_STAGE_ADDITIONSを設定して、
**「アセット(=出力するファイル)を追加するフックだよ〜」**てなことを教えています。
実際のところ、効用はよくわかりません。世の中には不思議がいっぱいだ。


フックの中にこんな箇所があります。

compilation.emitAsset(distPath, new RawSource(html));

distPathhtmlの中身はそれぞれこんな感じ。

console.log(distPath)
=> "index.html"

console.log(html)
=> "<!DOCTYPE html><html lang="ja"><head>....."

これを実行すると、webpack.config.jsのoutput.pathで指定したディレクトリに、
index.htmlという名称で"<!DOCTYPE html><html lang="ja"><head>....."という内容のファイルが出力されます。

$ npx webpack

[webpack-cli] Compilation finished
asset index.html 214 bytes [compared for emit]         <- これ
asset build.js 0 bytes [compared for emit] [minimized] (name: main)
./test/src/entry.js 1 bytes [built] [code generated]
webpack 5.10.0 compiled successfully in 597 ms

ここまででようやく、webpackを通してファイルを出力できるようになりました。

ちなみに、今回は使いませんでしたが、compilerオブジェクトではjsのコンパイル処理のかなり具体的なところまでアクセスできるようです。
https://webpack.js.org/api/parser/


プラグインのオプション

ソースコードの冒頭に、こんな箇所があります。

index.js
const schemaUnits = require('schema-utils');
...省略...

const schema = {
  target: {
    anyOf: [
      { type: 'array' },
      {
        type: 'object',
        propaties: {
          from: { type: 'string' },
          to: {
            anyOf: [
              {
                type: 'object',
                propaties: {
                  html: { type: 'string' },
                  css: { type: 'string' },
                },
              },
              { type: 'string' }
            ]
          },
        },
      }
    ]
  }
};
...省略...

class PugStylekitWebpackPlugin {
  constructor(options = {}){
    schemaUnits.validate(schema, options, { name: PLUGIN_NAME });
    this.options = options;
  }
...省略...

ここは何かというと、webpack.config.jsでこのプラグインを呼び出す際に設定するオプションのバリデーションを行う箇所です。
↓ここで渡すオプションですね。

webpack.config.js
...
new PugStyleKitWebpackPlugin({
    target: {
        from: SOURCE_DIR + '/index.pug',
        to: {
            html: OUTPUT_DIR + '/index.html',
            css: OUTPUT_DIR + '/style.css',
        },
    }
}),
...

schemaという変数にオプションの構造を定義しておき、コンストラクターの中でバリデーションを実行しています。
オプションの形式がschemaと合っていないと、ここでエラーが出ます。

バリデーションを通ったオプションオブジェクトは、this.optionsとして格納し後で使えるようにしておきます。

詳しいことはこの辺に書いてあります
https://github.com/webpack/schema-utils

ここまでくれば、あとはフックの中に好きな処理を書いていくだけです。


ここから先は、pug-stylekit-webpack-pluginに限った具体的な内容ですので、さらっと説明します。
処理が気になったら、実際にソースコードを覗いてみてくださいね。

Pugのコンパイル

fileStreamモジュールを使ってPugファイルを読み込み、Pugパッケージを使ってコンパイル、アセットに追加します。

index.js
const pug = require('pug');
const fs = require('fs');

...

const pugReadBuffer = fs.readFileSync(inputFile, 'utf8');
...
const html = pug.render(pugReadBuffer, options);
...
compilation.emitAsset(distPath, new RawSource(html));

Scssのコンパイル

Scssのコンパイルはちょっとややこしいですね。
Pugファイルからstylekitブロックを見つけ出す必要があります。

pug-lexerpug-parserという便利なパッケージがあります。

さきほどはPugパッケージを使って一気にコンパイルしましたが、
pug-parserを使ってPugの意味を解析し、stylekitブロックだけを取り出してみます。

index.js
const pugLexer = require('pug-lexer');
const pugParser = require('pug-parser');

...

  _parseFile(filename) {
    const buffer = fs.readFileSync(filename, 'utf8');
    const tokens = pugLexer(buffer, {filename});
    const ast = pugParser(tokens, {filename, buffer});
    return ast;
  }
...

astというのは抽象構文木(abstract syntax tree)の略です。
難しいことはおいといて、astの中身をみてみましょう。

console.log(ast)

=>
{
  "type": "Block",
  "nodes": [
    {
      "type": "Doctype",
      "val": "html",
      "line": 1,
      "column": 1,
      "filename": "src/index.pug"
    },
    {
      "type": "Tag",
      "name": "html",
      "selfClosing": false,
      "block": {
        "type": "Block",
        "nodes": [
          {
            "type": "Tag",
            "name": "head",
            "selfClosing": false,
            "block": {
              "type": "Block",
              "nodes": [
                {
                  "type": "Tag",
                  "name": "meta",
                  "selfClosing": false,
                  "block": {
                    "type": "Block",
                    "nodes": [],
                    "line": 4,
                    "filename": "src/index.pug"
                  },

...

                      {
                        "type": "BlockComment",
                        "val": " stylekit",
                        "block": {
                          "type": "Block",
                          "nodes": [
                            {
                              "type": "Text",
                              "val": ".body {",
                              "line": 9,
                              "column": 13,
                              "filename": "src/index.pug"
                            },
                            {
                              "type": "Text",
                              "val": "\n",
                              "line": 10,
                              "column": 1,
                              "filename": "src/index.pug"
                            },
                            {
                              "type": "Text",
                              "val": "  &__title { ",
                              "line": 10,
                              "column": 13,
                              "filename": "src/index.pug"
                            },
...

長くて読みづらいですが、Pugのブロックごとにオブジェクトとして分解されて、格納されているのがわかるかと思います。
stylekitブロックは、BlockCommentタイプと、1行ずつがTextタイプのval属性として解釈されました。

あとは、このTextタイプを1行ずつつなげたテキストを、Scssファイルとしてコンパイルするだけです。
テキストをsassBufferとして、Sass(dart-sass)パッケージに渡してコンパイルします。

index.js
const sass = require('sass')

...
  _createScss(blocks) {
    let sassBuffer = '';
    
    ... sassBufferに書き込む...

    return sass.renderSync({
      data: sassBuffer,
      outputStyle: "expanded",
    });
  }
...

renderSyncから返ってくるresultオブジェクトは、result.css.toString()とすることでcssのテキストに変換できます。
これを先ほど同様に、emitAssetしてあげればOKです。

index.js
const resultSass = this._createScss(styleKitBlocks);
const distPath = path.relative(outputPath, outputFile.css);
compilation.emitAsset(distPath, new RawSource(resultSass.css.toString()));

最後に

最後はちょっと駆け足気味になってしまいましたが、
詳細は実際のコードをみていただくのが早いかと思います。

webpackはなかなか奥が深いですね。
フックの一覧をみるだけでも、もっといろいろできる気がしてきます。

webpackではpluginだけでなく、loaderも自作することができます。
どちらも、イメージよりずっと簡単に実装ができます。

もし機会があったら、是非チャレンジしてみてください。


去年に引き続き長文になってしまいましたが、お読みいただきありがとうございました。

弊社ではこの記事に引き続き、NIJIBOX Advent Calendar 2020として記事を公開しております。
ガッツリコードを書く記事から、チームビルディングまで多種多様な内容となっておりますので、
是非読んでみていただけますと幸いでございます。

それでは、メリークリスマス。

おしまい。

※オチは特にありません。

18
7
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
18
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?