JavaScript 書式を指定して日時出力 和暦対応 引用囲み対応

# 概要

JavaScriptでは、日付型をYYYY/MM/ddなどの指定したフォーマットで文字列変換してくれる、というものが標準ではないので、それを作ってみました。

普通には日時ライブラリとして優れている moment.js 使え、ってことになると思うので、普通にはそれを使ったらいいと思います。

何十番目かの車輪の再発明かと思いますが、より性能のよい補助輪を再発明にトライしてみています。書式ルールは、主に.NETを参考にしました。

特徴としては、

* フォーマットのルールを指定できるので、ルールの追加や変更ができる
* ダブルあるいはシングルクウォートで囲まれた文字は形式文字でも変換しない
* 日本語ルールでは和暦出力に対応。年の区切りではなく、特定日より前と後で元号を区別。

という機能を持ちます。

# 使い方

次のHTMLファイルを用意して動作確認できます。

~~~ index.html
<!DOCTYPE html>
<html lang="ja"><head>
  <meta charset="utf-8">

  <script src="https://cdn.rawgit.com/standard-software/stsLib.js/master/Source/stsLib.js/stslib_core.js"></script>

<script>

var main = function () {
  var stsLib = require('stsLib');
  var today = new Date();

  console.log(stsLib.date.formatToString(today, 'yyyy/MM/dd dddd HH:mm:ss.fff'));   //2017/10/23 Monday 10:19:55.019
  console.log(stsLib.date.formatToString(today, 'yyyy年M月d日(ddd) h:m:s.ff tt'));  //2017年10月23日(Mon) 10:19:55.01 AM
  console.log(stsLib.date.formatToString(today, 'd-MMMMM-yy'));                     //23-October-17
  console.log(stsLib.date.formatToString(today, 'ddd MMMM dd yyyy'));               //Mon Oct. 23 2017
  console.log(stsLib.date.formatToString(today, 'ddd MMM dd yyyy'));                //Mon Oct 23 2017
  console.log(stsLib.date.formatToString(today, '"yyyyは"yyyyに変換されます。'));   //yyyyは2017に変換されます。

  var rule = stsLib.date.formatRuleDefaultJp();
  console.log(stsLib.date.formatToString(today, 'ggggYY年M月d日(DDD)', rule));      //平成29年10月23日(月)
  console.log(stsLib.date.formatToString(today, 'ggY年M月d日(DDDD)', rule));        //平29年10月23日(月曜日)

};
document.addEventListener("DOMContentLoaded",function(eve){
  main();
},false);

</script>

</head><body>
</body></html>
~~~

# 書式

このような仕様です。
ソースコードが仕様だ。というのも申し訳ないですが、細かくはソースを追ってほしいです。いつから昭和が始まるのとか。4桁ではない西暦のときとか。

|文字列|変換内容|
|:-----------|:-----------|
|yyyy|西暦、4桁、先頭ゼロ埋め|
|yyy|西暦、先頭ゼロ埋めなし|
|yy|西暦、2桁、先頭ゼロ埋め|
|y|西暦、2桁、先頭ゼロ埋めなし|
|MM|月、2桁、先頭ゼロ埋め|
|M|月、先頭ゼロ埋めなし|
|dd|日、2桁、先頭ゼロ埋め|
|d|日、先頭ゼロ埋めなし|
|HH|24時間表記時間、先頭ゼロ埋め(00～23)|
|H|24時間表記時間、先頭ゼロ埋めなし(0～23)|
|hh|12時間表記時間、先頭ゼロ埋め(00～12)|
|h|12時間表記時間、先頭ゼロ埋めなし(0～12)|
|mm|分、先頭ゼロ埋め|
|m|分、先頭ゼロ埋めなし|
|ss|秒、先頭ゼロ埋め|
|s|秒、先頭ゼロ埋めなし|
|fff|ミリ秒(000～999)|
|ff|1/100秒(00～99)|
|f|1/10秒(0～9)|
|tt|午前午後、2文字、AM もしくは PM
|t|午前午後、1文字、A もしくは P
|MMM|月、3文字、Jan/Feb/Mar 等|
|MMMM|月、4文字、Jan./Feb./Mar. 等、Mayだけ3文字|
|MMMMM|月、January/February/March 等|
|ddd|曜日、3文字、Sun/Mon/Tue 等|
|dddd|曜日、Sunday/Monday/Tuesday 等|


日本語ルール追加部分です。

|文字列|変換内容|
|:-----------|:-----------|
|DDD|日/月/火 等|
|DDDD|日曜日/月曜日/火曜日 等|
|TTTT|午前 もしくは 午後
|gggg|平成/昭和/大正/明治|
|gg|平/昭/大/明|
|Y|和暦の年、先頭ゼロ埋めなし|
|YY|和暦の年、2桁、先頭ゼロ埋め|


# 実装

使う方法は、scriptタグのCDNのリンク先で示しています。
GitHubではここに配置しています。

[stsLib.js/stslib_core.js at master · standard-software/stsLib.js](https://github.com/standard-software/stsLib.js/blob/master/Source/stsLib.js/stslib_core.js)

HTMLでもbrowserifyでもnode.jsでも、WSH JScriptでも動くのでリンク方法はGitHubで動作確認しているところを参考ください。

内部は、ライブラリ部品を多く使っているので、stsLib.date.formatToString の部分だけ
コピペして動く、というものではないですが、次のようになっています。

アンダーバーや[t]とか[s]とかアルファベット1文字で示されているところは、
ライブラリオブジェクトの名前空間を指すだけなので
手間かけてコピペする場合は、そのあたりはどんどん削れば動くと思います。

~~~js:stslib_core.js

      _.formatToString = function(date, format, rule) {
        c.assert(t.isDate(date));
        c.assert(t.isString(format));
        rule = t.ifNullOrUndefinedValue(rule, _.formatRuleDefault());
        c.assert(t.isObject(rule));
        var singleQuoteIndex = s.indexOfFirst(format, "'");
        var doubleQuoteIndex = s.indexOfFirst(format, '"');
        c.assert(!(
          (singleQuoteIndex !== -1) && (doubleQuoteIndex !== -1)
          //書式には[']と["]の混在は許可しない
        ), 'Error:stsLib.date.formatToString:Quote Exists ["]and[\'].');


        //置き換え配列を作成する。長いもの順にする。
        var keys = stsLib.object.property.names(rule);
        a.sortPattern(keys, 'length', 'descending');
        var replaceArray = [];
        for (var i = 0, l = keys.length; i < l; i += 1) {
          replaceArray.push([
            keys[i], rule[keys[i]](date)
          ]);
        }

        var quoteChar;
        if ((singleQuoteIndex === -1) && (doubleQuoteIndex === -1)) {

          //クウォート記号がないのならば
          //通常通り要素に変換をかける
          return s.replaceAllAny(format, replaceArray);
        } else if (singleQuoteIndex === -1) {
          quoteChar = '"';
        } else if (doubleQuoteIndex === -1) {
          quoteChar = "'";
        }
        c.assert(n.isEven(s.includeCount(format, quoteChar)),
          'Error:stsLib.date.formatToString:Quote Count is not Even.');
        //クウォートの個数は偶数であること

        var formatStrs = format.split(quoteChar);
        for (var i2 = 0, l2 = formatStrs.length; i2 < l2; i2 += 2) {
          formatStrs[i2] = s.replaceAllAny(formatStrs[i2], replaceArray);
        }
        return formatStrs.join('');
      };

~~~


簡単に内容を説明すると、

* 引数の型をチェックして変ならエラーにする
* シングルクウォートかダブルクウォートかどちらかだけしか許可しない
* クウォート記号は偶数個使われていることしか許可しない
* ruleオブジェクトからプロパティ名称を取り出して長いもの順にする
  (置き換えの必要上、長い文字から置き換えるため)
* ruleオブジェクトのプロパティ名に該当する内容を指定されたdateに従って置き換えるために
  置き換え配列に配置する
* クウォート記号がないなら単純な置き換えを行う
* クウォート記号があるなら、元のフォーマットをクウォート記号で配列に分解
  奇数index部分のところにだけ処理を行う
  (クウォート記号で囲まれたフォーマット記号には変換を行わないため)

このような仕組みになります。

# ルール指定

変換ルールは、何も指定されていない場合はデフォルトルールが使われます。
デフォルトルールは英語系の処理だけにしたので、そこに内容を追加した日本語ルールも作成しています。曜日の日本語文字や、和暦で年を出力したいときは、日本語ルールを使っています。

和暦については、適当にネットで定義みただけなので間違いあれば、指摘ください。

昭和とか大正とかの開始日が厳密には重複した日だったりするみたいですが、よくわからないので、開始日で区切りました。
明治の開始も仕様がよくわからないですが、西暦で特定日以降は明治。それ以前は西暦を返しています。

標準的なものを意識して.NETの変換ルールを真似て作成しました。
ルールを変更して指定すると自分好みの変換ルールが使えます。

ルールオブジェクトのプロパティに追加したりあるいは削除したりするだけなので、和名の月名を組み込む、などといったことも簡単に思います。

曜日や月名は、別で使うこともあるので別関数で用意してそれを呼び出しています。

次のようになります。

~~~js:stslib_core.js

      _.formatRuleDefault = function() {
        var formatRule = {};
        formatRule.yyyy = function(date) {
          //西暦4桁以下、4桁に先頭ゼロ埋め
          return s.fillStart(date.getFullYear().toString(), 4, '0');
        };
        formatRule.yyy = function(date) {
          //西暦数値
          return date.getFullYear().toString();
        };
        formatRule.yy = function(date) {
          //西暦下2桁固定、先頭ゼロ埋め
          return s.end(s.fillStart(date.getFullYear().toString(), 4, '0'), 2);
        };
        formatRule.y = function(date) {
          //西暦下2桁切取、1桁か2桁
          return t.convertToInt(
            s.end(s.fillStart(date.getFullYear().toString(), 4, '0'), 2)
          ).toString();
        };
        formatRule.MM = function(date) {
          return s.fillStart((date.getMonth() + 1).toString(), 2, '0');
        };
        formatRule.M = function(date) {
          return (date.getMonth() + 1).toString();
        };
        formatRule.dd = function(date) {
          return s.fillStart(date.getDate().toString(), 2, '0');
        };
        formatRule.d = function(date) {
          return date.getDate().toString();
        };
        formatRule.hh = function(date) {
          //00～12
          return s.fillStart((date.getHours() % 12).toString(), 2, '0');
        };
        formatRule.h = function(date) {
          //0～12
          return (date.getHours() % 12).toString();
        };
        formatRule.HH = function(date) {
          //00～23
          return s.fillStart(date.getHours().toString(), 2, '0');
        };
        formatRule.H = function(date) {
          //0～23
          return date.getHours().toString();
        };
        formatRule.mm = function(date) {
          return s.fillStart(date.getMinutes().toString(), 2, '0');
        };
        formatRule.m = function(date) {
          return date.getMinutes().toString();
        };
        formatRule.ss = function(date) {
          return s.fillStart(date.getSeconds().toString(), 2, '0');
        };
        formatRule.s = function(date) {
          return date.getSeconds().toString();
        };
        formatRule.fff = function(date) {
          //  1/1000秒
          return s.fillStart(date.getMilliseconds().toString(), 3, '0');
        };
        formatRule.ff = function(date) {
          //  1/100秒
          return s.start(
            s.fillStart(date.getMilliseconds().toString(), 3, '0'), 2);
        };
        formatRule.f = function(date) {
          //  1/10秒
          return s.start(
            s.fillStart(date.getMilliseconds().toString(), 3, '0'), 1);
        };
        formatRule.tt = function(date) {
          return date.getHours() < 12 ? 'AM' : 'PM';
        };
        formatRule.t = function(date) {
          return date.getHours() < 12 ? 'A' : 'P';
        };
        formatRule.ddd = function(date) {
          return d.dayOfWeekEn(date);
        };
        formatRule.dddd = function(date) {
          return d.dayOfWeekEnglish(date);
        };
        formatRule.MMM = function(date) {
          return d.nameOfMonthEn3Char(date);
        };
        formatRule.MMMM = function(date) {
          return d.nameOfMonthEn4Char(date);
        };
        formatRule.MMMMM = function(date) {
          return d.nameOfMonthEnglish(date);
        };
        return formatRule;
      };

      _.formatRuleDefaultJp = function() {
        var formatRule = _.formatRuleDefault();
        formatRule.DDD = function(date) {
          return d.dayOfWeekJp(date);
        };
        formatRule.DDDD = function(date) {
          return d.dayOfWeekJapanese(date);
        };
        formatRule.TTTT = function(date) {
          return date.getHours() < 12 ? '午前' : '午後';
        };

        var japaneseCalenderYear = function(date) {
          var result = {};
          var heiseiStart = _.Date(1989,  1,  8);
          var shouwaStart = _.Date(1926, 12, 25);
          var taishoStart = _.Date(1912,  7, 30);
          var meijiStart  = _.Date(1868,  9,  8);
          if (heiseiStart <= date) {
            result.gengo = '平成';
            result.year = (formatRule.yyyy(date) - heiseiStart.getFullYear() + 1);
          } else if (shouwaStart <= date) {
            result.gengo = '昭和';
            result.year = (formatRule.yyyy(date) - shouwaStart.getFullYear() + 1);
          } else if (taishoStart <= date) {
            result.gengo = '大正';
            result.year = (formatRule.yyyy(date) - taishoStart.getFullYear() + 1);
          } else if (meijiStart <= date) {
            result.gengo = '明治';
            result.year = (formatRule.yyyy(date) - meijiStart.getFullYear() + 1);
          } else {
            result.gengo = '';
            result.year = formatRule.yyyy(date);
          }
          return result;
        }

        formatRule.gggg = function(date) {
          //平成
          return japaneseCalenderYear(date).gengo;
        };
        formatRule.gg = function(date) {
          //平
          return s.start(japaneseCalenderYear(date).gengo, 1);
        };
        formatRule.Y = function(date) {
          //和暦年数値
          return japaneseCalenderYear(date).year.toString();
        };
        formatRule.YY = function(date) {
          //和暦年2桁固定
          return s.end(s.fillStart(formatRule.Y(date), 2, '0'), 2);
        };
        return formatRule;
      };


      //----------------------------------------
      //◇月の名前
      //----------------------------------------

      _.nameOfMonth = function(date, monthNames) {
        c.assert(t.isDate(date));
        c.assert(t.isArray(monthNames));
        c.assert(monthNames.length === 12);
        return monthNames[date.getMonth()];
      };

      _.nameOfMonthEn3Char = function(date) {
        return _.nameOfMonth(date, _.monthNamesEn3Char());
      };

      _.nameOfMonthEn4Char = function(date) {
        return _.nameOfMonth(date, _.monthNamesEn4Char());
      };

      _.nameOfMonthEnglish = function(date) {
        return _.nameOfMonth(date, _.monthNamesEnglish());
      };

      _.monthNamesEn3Char = function() {
        return [
          'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
          'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'
        ];
      };

      _.monthNamesEn4Char = function() {
        return [
          'Jan.', 'Feb.', 'Mar.', 'Apr.', 'May' , 'June',
          'July', 'Aug.', 'Sep.', 'Oct.', 'Nov.', 'Dec.'
        ];
      };

      _.monthNamesEnglish = function() {
        return [
          'January', 'February', 'March', 'April', 'May' , 'June',
          'July', 'August', 'September', 'October', 'November', 'December'
        ];
      };

      //----------------------------------------
      //◇曜日
      //----------------------------------------

      //----------------------------------------
      //・配列を指定して曜日文字列を出力する
      //----------------------------------------
      //  ・配列指定ない場合は date.getDay() (日=0～土=6)を返す
      //----------------------------------------
      _.dayOfWeek = function(date, dayOfWeekNames) {
        c.assert(t.isDate(date));
        if (t.isNullOrUndefined(dayOfWeekNames)) {
          return date.getDay();
        }
        c.assert(t.isArray(dayOfWeekNames));
        c.assert(dayOfWeekNames.length === 7);
        return dayOfWeekNames[date.getDay()];
      };

      _.dayOfWeekEn = function(date) {
        return _.dayOfWeek(date, _.dayOfWeekNamesEn());
      };

      _.dayOfWeekEnglish = function(date) {
        return _.dayOfWeek(date, _.dayOfWeekNamesEnglish());
      };

      _.dayOfWeekJp = function(date) {
        return _.dayOfWeek(date, _.dayOfWeekNamesJp());
      };

      _.dayOfWeekJapanese = function(date) {
        return _.dayOfWeek(date, _.dayOfWeekNamesJapanese());
      };

      _.dayOfWeekNamesEn = function() {
        return [
          'Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'
        ];
      };

      _.dayOfWeekNamesEnglish = function() {
        return [
          'Sunday', 'Monday', 'Tuesday', 'Wednesday',
          'Thursday', 'Friday', 'Saturday'
        ];
      };

      _.dayOfWeekNamesJp = function() {
        return [
          '日', '月', '火', '水', '木', '金', '土'
        ];
      };

      _.dayOfWeekNamesJapanese = function() {
        var result = _.dayOfWeekNamesJp();
        for (var i = 0, iMax = result.length; i < iMax; i += 1) {
          result[i] = result[i] + '曜日';
        }
        return result;
      };

~~~
# 参考

次の内容を参考にさせていただきました。
とても勉強になりました。ありがとうございます。

[日付フォーマットなど 日付系処理 - Qiita](https://qiita.com/osakanafish/items/c64fe8a34e7221e811d0)

[Javascriptで日付・時刻をフォーマット表示 - Qiita](https://qiita.com/pseudo_foxkeh/items/9297a834bc8e05133e7a)

[javascriptで日付フォーマッタ - Qiita](https://qiita.com/egnr-in-6matroom/items/37e65bb642d2e158804c)

[JavaScriptで日付をフォーマットする（和暦対応） - Qiita](https://qiita.com/hrdaya/items/4e6c53c0d07d63ea0af3)

# 注意
和暦に、toLocaleDateString を使っているテクニックがあるけれども、実装環境依存するようなので、自前で実装したほうがいいと思いましたよ。

# 追記

公開しているライブラリのParts.js v10.4.0 に組み込みました。

https://github.com/standard-software/partsjs/tree/v10.4.0/source/date
_datetimeToString.js / __detetimeToStringFunc.js が主な実装になります。
Momentの書式文字列をすべてではないですがある程度採用してものと、Momentではちょっと不足しているかなと思われる書式文字列を追加しています。

.NETライクな書式文字列などのルールを自在に定義してもいいかもです。