⚒️

npm scriptsでPugをコンパイルする(JSONの参照、Markuplintの導入)

2023/09/14に公開

要件

  • srcディレクトリにあるPugファイルを対象にコンパイルしてhtdocsディレクトリに出力する
  • PugからJSONを参照できるようにする(サイト共通データなどを管理)
  • Pugファイルの品質はMarkuplintで担保する
  • ページ作成用のPugファイルでサイト共通の値や関数を使えるようにしつつ、ページに合わせて上書き可能にする

パッケージをインストールする

Pugに必要なパッケージをインストールします。

npm i -D pug github:pugjs/pug-cli#master

スタイルガイドにFractalを使用する場合はpugではなくpug@2.0.0-rc.4でインストールしてください。
@rsm/fractal-pug-adapterをインストールしたときにPugの3系が使えず、2系に落とすようにコマンドの指示がありました。

また、pug-cligithub:pugjs/pug-cli#masterをインストールしてください。
こちらの記事にある通り、pug-cli 1.0.0-alpha6には_から始まるPugファイルもHTMLとして出力されてしまうバグがあります。
2023年9月14日時点でも1.0.0-alpha6が最新(2016年6月2日から更新なし)なので、本体が修正されるのは難しいかもしれません。

Markuplintも使いたいのでインストールします。

npm i -D markuplint @markuplint/pug-parser

クロスプラットフォームに対応して簡潔に記述できるnpm-run-allとファイルの監視機能があるonchangeもインストールします。

npm i -D npm-run-all onchange

ディレクトリ構造

以下のディレクトリ構造で構成しています。

.
├── _data
│   ├── data.json
│   └── json
│       ├── fooList.json
│       └── site.json
├── _mixin
│   ├── _Icon.pug
│   ├── _LinkText.pug
│   ├── _Picture.pug
│   ├── _SrOnly.pug
│   └── _all.pug
├── _partial
│   └── _meta.pug
├── _template
│   └── _default.pug
└── index.pug
  • _data:サイトの共通データや一覧などのコンテンツデータです
  • _mixin:mixinを一括管理します
  • _partial:メタタグやグローバルヘッダーなどを管理します
  • _template:上記をすべて_default.pugとして集約します
  • index.pug_default.pugを継承してページを作成します

サイトの共通データを1枚のJSONファイルに結合する

--objオプションにJSONファイルを渡すと、すべてのPugファイルから参照できます。
ただ、複数のファイルを渡すことは出来なさそうだったので、スクリプトを事前に実行して1つのファイルにまとめています。

scripts/mergeJson.js
const fs = require('fs');
const path = require('path');

/**
 * 指定されたディレクトリ内のJSONファイルをマージします。
 * @param {string} inputDir - JSONファイルが格納されているディレクトリのパス
 * @returns {Object} マージされたJSONデータ
 */
const getMergedData = (inputDir) => {
  return fs.readdirSync(inputDir).reduce((acc, file) => {
    if (path.extname(file) === '.json') {
      const key = path.basename(file, '.json');
      const data = require(path.join(inputDir, file));
      acc[key] = data;
    }
    return acc;
  }, {});
};

// 入力ディレクトリと出力ディレクトリの設定
const inputDir = path.join(__dirname, '../src/_data/json');
const outputDir = path.join(__dirname, '../src/_data');
const outputFile = path.join(outputDir, 'data.json');

// マージされたデータを取得
const mergedData = getMergedData(inputDir);

// マージされたデータをファイルに出力
fs.writeFileSync(outputFile, JSON.stringify(mergedData, null, 2));

_data/json/以下にJSONファイルを保存、スクリプトを実行すると_data/data.jsonとして出力されます。

.
├── _data
│   ├── data.json
│   └── json
│       ├── fooList.json
│       └── site.json

site.jsonfooList.jsonが次の内容だったとすると、

site.json
{
  "url": "https://example.com",
  "name": "サイト名",
  "title": "サイトのタイトル",
  "description": "サイトの説明文",
  "keywords": "キーワード1,キーワード2,キーワード3",
  "ogpImage": "/ogp.png",
  "fbAppId": "iiiiiiiiiiiiiii",
  "fbAdmins": "aaaaaaaaaaaaaaa",
  "twitterCard": "summary_large_image",
  "twitterSite": "@account_name",
  "gtm": "GTM-XXXXXX"
}
fooList.json
{
  "data": [
    { "id": "dummy1", "text": "テキスト1", "url": "/dummy1/", "external": true },
    { "id": "dummy2", "text": "テキスト2", "url": "/dummy2/", "external": false }
  ]
}

次のように、ファイル名をキーにして結合されます。

data.json
{
  "fooList": {
    "data": [
      {
        "id": "dummy1",
        "text": "テキスト1",
        "url": "/dummy1/",
        "external": true
      },
      {
        "id": "dummy2",
        "text": "テキスト2",
        "url": "/dummy2/",
        "external": false
      }
    ]
  },
  "site": {
    "url": "https://example.com",
    "name": "サイト名",
    "title": "サイトのタイトル",
    "description": "サイトの説明文",
    "keywords": "キーワード1,キーワード2,キーワード3",
    "ogpImage": "/ogp.png",
    "fbAppId": "iiiiiiiiiiiiiii",
    "fbAdmins": "aaaaaaaaaaaaaaa",
    "twitterCard": "summary_large_image",
    "twitterSite": "@account_name",
    "gtm": "GTM-XXXXXX"
  }
}

Markuplintを設定する

.markuplintrcを作成します。

touch .markuplintrc

.markuplintrcには次のように設定しています。

.markuplintrc
{
  "extends": [
    "markuplint:recommended"
  ],
  "parser":{
    ".pug$": "@markuplint/pug-parser"
  },
  "nodeRules": [
    {
      "selector": "img",
      "rules": {
        "required-attr": [
          "src",
          "alt"
        ]
      }
    }
  ]
}

設定 | Markuplint

  • プリセットは「推奨プリセット」であるmarkuplint:recommendedを設定、今回はPugファイルを直接リントするので追加プリセットは設定していません
  • parserには@markuplint/pug-parserを設定しています
  • nodeRulesでimgタグの必須属性をsrcaltだけに上書きしています(widthheightdecodingの必須設定をリセットしています)

Pugファイルを設定する

index.pug

index.pug
extend /_template/_default
append variables
  - const pageAbsolutePath = "/"; // ページのルート相対パス(必須)
  - const pageTitle= `${site.title}`; // ページタイトル(必須)
  - const pageDescription= site.description; // ページのメタディスクリプション(必須)
  - const pageKeywords= site.keywords; // ページのメタキーワード(任意)
  - const pageLocale= "ja"; // ページの言語(必須)
  - const pageOgpType= "website"; // ページのOGPタイプ(必須)
  - const pageOgpTitle= pageTitle; // ページのOGPタイトル(必須)
  - const pageOgpImage= site.ogpImage; // ページのOGP画像(必須)

block content
  article
    ul
      each item in fooList.data
        li
          a.common-LinkText(href=item.url target=item.external && "_blank")
            span !{item.text}
  • block content内にコンテンツを追加します
  • append variables内に変数を定義、既存の変数は_partial/_meta.pugで使用しています
  • target=item.external && "_blank"の箇所は、externaltrueだったらtarget="_blank"が出力されます
  • !{変数名}のようにするとHTMLとして解釈されるので、brタグを入れることができるようになります

ページ固有のCSSとJSを読み込みたい場合は次のように追加できます。

index.pug
//- 個別CSSファイルを追加で読み込む
block append css
  - link(rel="stylesheet" href="/assets/css/xxx.css")
//- 個別JSファイルを追加で読み込む
block append js
  - script(src="/assets/js/xxx.js")

block content

_template/_default.pug

_default.pug
block variables
include /_mixin/_all

doctype html
html(lang=pageLocale)
  head
    include /_partial/_meta

  body
    // Google Tag Manager (noscript)
    noscript
      iframe(src=`https://www.googletagmanager.com/ns.html?id=${site.gtm}` height="0" width="0" style="display:none;visibility:hidden")
    // End Google Tag Manager (noscript)

    //- include /_partial/header

    block content

    //- include /_partial/footer
  • include /_mixin/_allでmixinを一括で参照しています
  • /_partial/_metaの他、/_partial/headerなどを追加していきます

_mixin/_all.pug

_all.pug
include /_mixin/_Icon
include /_mixin/_SrOnly
include /_mixin/_Picture
include /_mixin/_LinkText
  • src/_mixin/内にあるmixin用のPugファイルをすべてインクルードします

_Icon.pugであれば次のように定義しています。

_Icon.pug
//- @param {Object} params
//- @param {String} params.name [""] SVGスプライトのid名
//-
//- @examples Input
//- +Icon({ name: "menu" })
//- @examples Output
//- <svg role="img">
//-   <use href="/assets/svg/sprite.svg#menu"></use>
//- </svg>
mixin Icon(params={})
  -
    const props = Object.assign({
      name: "",
    }, params)

  svg(role="img")&attributes(attributes)
    use(href=`/assets/svg/sprite.svg#${props.name}`)

_partial/_meta.pug

_meta.pug
meta(charset="UTF-8")
meta(name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no")

// Google Tag Manager
script.
  (function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
  new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
  j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
  'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
  })(window,document,'script','dataLayer',`#{site.gtm}`);
// End Google Tag Manager

title #{pageTitle}
meta(name="description" content=pageDescription)
if pageKeywords
  meta(name="keywords" content=pageKeywords)

block font
  .
    <link rel="preconnect" href="https://fonts.googleapis.com">
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
    <link href="https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@300;400;500;700&display=swap" rel="stylesheet">

block css
  link(rel="stylesheet" href="/assets/css/site.css")

link(rel="canonical" href=site.url + pageAbsolutePath)

meta(name="format-detection" content="telephone=no")

meta(property="og:title" content=pageOgpTitle)
meta(property="og:description" content=pageDescription)
meta(property="og:type" content=pageOgpType)
meta(property="og:image" content=site.url + pageOgpImage)
meta(property="og:url" content=site.url + pageAbsolutePath)
meta(property="og:site_name" content=site.name)
meta(property="og:locale" content=pageLocale)

if site.fbAppId
  meta(property="fb:app_id" content=site.fbAppId)
else if site.fbAdmins
  meta(property="fb:admins" content=site.fbAdmins)

if site.twitterCard
  meta(name="twitter:card" content=site.twitterCard)
if site.twitterSite
  meta(name="twitter:site" content=site.twitterSite)

block js
  script(src="/assets/js/site.js" defer)
  • siteから始まる変数は_data/json/site.jsonから参照しています
  • pageから始まる変数はindex.pugで定義していますが、index.pugでもsiteから始まる変数を使うこともできます

npm scriptsを設定する

Pugのコンパイルに関する処理だけ抜き出しています。

package.json
  "scripts": {
    "start": "run-s -c dev watch",
    "build": "npm-run-all -s clean -p build:*",
    "watch": "run-p watch:*",
    "watch:html": "onchange \"src/**/*.pug\" \"src/_data/json/**/*.json\" -- npm run dev:html",
    "dev": "run-p dev:*",
    "dev:html": "NODE_ENV=development npm-run-all -c -s html:{json,pug} -s html:markuplint",
    "build:html": "NODE_ENV=production run-s -c html:*",
    "html:json": "node scripts/mergeJson.js",
    "html:markuplint": "markuplint \"src/**/*.pug\"",
    "html:pug": "pug src --out htdocs --basedir src --obj src/_data/data.json --pretty",
  },
  • devは開発用のビルド、buildは本番用のビルドとしています
  • run-sは順番に実行、-cはエラーが起きても最後の処理まで止まらずに実行するオプションです
  • node scripts/mergeJson.jsを実行してPugに渡すJSONを作成します
  • run-s -c html:*のようにすると、scriptsにあるhtml:から始まるスクリプトを上から順番に実行します
  • html:pugでは、コンパイル元・出力先・ルート相対パスのルート指定・JSONファイルの参照先・整形された状態でHTML出力(設定なしだと1行で出力)を設定しています
  • npm-run-all -c -s html:{json,pug} -s html:markuplintの部分で一連の処理を開始、JSON作成をしてからPugをコンパイル、その後にMarkuplintを実行します
  • onchange \"src/**/*.pug\" \"src/_data/json/**/*.json\" -- npm run dev:htmlの部分でPugファイルと関連するJSONファイルの保存を監視、ファイルが上書きされたら一連の処理を開始します

Discussion