Movable Type CMSプラットフォーム Movable Type
ドキュメントサイト

Blogブログ

コンテンツタイプのフィールドタイプを増やしてみよう

HAPPY HOLIDAY !!

こんにちわ。シックス・アパートの高山です。

この記事は、Movable Type Advent Calendar 2018 の 25日目の記事です。

Movable Type 7 といえば、コンテンツタイプです。これまでの記事やウェブページにカスタムフィールドを加えるような感覚でありながら、まったく新しいコンテンツ構造をいちから設計できる機能になります。

端的に説明すると、以下のような特徴を持っています。

  • コンテンツタイプには、自由にコンテンツの名称を付けられます。これにより。「記事」や「ウェブページ」という用語を読み替える、或いは管理画面をカスタマイズしてラベルを変更する。といったことが必要ありません。
  • 「本文」や「タイトル」といった、システムから提供する(押し付ける)フィールドは一つもありません。(データ識別ラベルをユーザー入力とした場合を除く)すべてのフィールドを自由に設計できます。
  • カスタムフィールドで利用できるフィールドの種類以上に利用できるフィールドの種類が存在します。
  • コンテンツタイプ同士をリンクすることができるので、関連するコンテンツや使い回しができる情報を別のコンテンツタイプとして作成・管理することができます。これにより、様々な場所で利用されている共通の情報を更新するとき、利用しているコンテンツを検索、編集することなく、一つのコンテンツを編集するだけで済ませることができます。

さて、Movable Type といえばプラグインによる機能拡張(自由度高め)が特徴的ですが、コンテンツタイプのフィールドの種類(以後フィールドタイプと呼びます)を増やすことももちろんプラグインでできます。

この記事では、フィールドタイプを追加する方法について説明します。

(ようやく本題にはいります。)

新しいフィールドタイプを登録する

フィールドタイプを追加するには、「content_field_types」というレジストリにフィールドの定義を追加する必要があります。

今回は、タイトルと説明を組み合わせて入力する事ができるフィールド(説明リストや定義リストと言われるもの DL タグ)を追加しますので、「content_field_types」の下に「description_list」というキーで登録します。このキー名がコンテンツデータのの中でも利用されますので、カブりは禁止です。

なお、今回説明に利用したプラグインは GitHub で公開しています。例によってテクニカルサポート非対応にはなりますが、ご利用ください。(将来的にはコアに取り込むかも)

すべての定義を記入したものがこちらです。

id: DescriptionListField
name: DescriptionListField
version: 1.0
author_name: Six Apart Ltd.
author_link: https://movabletype.org
description: <__trans phrase="This plugin adds a description list content field type.">
l10n_lexicon:
    ja:
        This plugin adds a description list content field type.: "説明リストコンテンツフィールドを追加"
        Description List: 説明リスト
        Title: 用語
        Value: 説明
        Add new [_1]: 新しい[_1]を追加
        Title Label: タイトルのラベル
        Value Label: 説明のラベル

content_field_types:
    description_list:
        label: Description List
        data_type: 'text'
        order: 210,
        icon_class: 'ic_list'
        can_data_label_field: 0
        data_load_handler: $DescriptionListField::DescriptionListField::ContentFieldType::DescriptionListField::data_load_handler
        field_html: 'field_html/field_html_description_list.tmpl'
        field_html_params: $DescriptionListField::DescriptionListField::ContentFieldType::DescriptionListField::field_html_params
        field_value_handler: $DescriptionListField::DescriptionListField::ContentFieldType::DescriptionListField::field_value_handler
        replaceable: 1
        tag_handler: $DescriptionListField::DescriptionListField::ContentFieldType::DescriptionListField::tag_handler
        feed_value_handler: $DescriptionListField::DescriptionListField::ContentFieldType::DescriptionListField::feed_value_handler
        preview_handler: $DescriptionListField::DescriptionListField::ContentFieldType::DescriptionListField::preview_handler
        replace_handler: $DescriptionListField::DescriptionListField::ContentFieldType::DescriptionListField::replace_handler
        search_handler: $DescriptionListField::DescriptionListField::ContentFieldType::DescriptionListField::search_handler
        list_props:
            description_llist:
                base: '__virtual.string'
                col: 'value_varchar'
                display: 'none'
                filter_editable: 0
                html: $DescriptionListField::DescriptionListField::ContentFieldType::DescriptionListField::list_prop_html
                terms: $DescriptionListField::DescriptionListField::ContentFieldType::DescriptionListField::list_prop_terms
        options_html: 'content_field_type_options/description_list.tmpl'
        options:
            - label
            - description
            - required
            - display
            - title_label
            - value_label

レジストリの登録内容

それぞの内容について解説します。

label
コンテンツタイプの編集画面で表示されるフィールドタイプ名です。自動翻訳の対象となります。
data_type
データベースにデータを格納する際のデータ型を指定します。今回は、説明文が長くなったりすることを考慮して「text」とします。それ以外にも、「varchar」や「integer」などが利用できます。
order
コンテンツタイプ編集画面のフィールド一覧におけるフィールドの表示順です。既存のフィールドタイプは、10 から始まり 200 まで 10 刻みで定義されています。既存のフィールドタイプの間や先頭に表示したいという場合は、この値で調整します。
icon_class
コンテンツタイプ編集画面のフィールド一覧におけるフィールドのアイコンを指定します。利用できるアイコンの種類は MT7 Style Guide に掲載されています。
can_data_label_field
1 を指定すると、コンテンツタイプの編集画面でデータ識別ラベルとして利用するフィールドの選択に登場します。保存されている値がそのまま利用されるので、今回のように特殊な値の場合は 0 を指定します。
data_load_handler
コンテンツデータ入力画面から、フィールドの値を取得するハンドラーを登録します。
field_html
コンテンツデータ編集画面で表示されるフィールドのテンプレートを指定します。
field_html_params
field_html で利用するテンプレート変数を仕込むためのハンドラーを指定します。
field_value_handler
MTContentFieldValue タグの結果を返すハンドラーを指定します。指定がない場合は、入力された値をそのまま利用します。
replaceable
管理画面の検索と置換により、置換ができる場合は 1 を指定します。
tag_handler
MTContentField タグのハンドラーを指定します。複数の値を保つ場合、ループの処理を行ったり、他のオブジェクトへのリンクなどでMTContentField タグのコンテクストを変更する場合に指定します。今回は、複数の説明が存在する可能性があるので指定します。
feed_value_handler
コンテンツデータの一覧から取得できるコンテンツデータフィードに表示する内容を出力するためのハンドラーを指定します。指定がない場合は、入力された値をそのまま利用します。
preview_handler
個別アーカイブが存在しないときにデータのプレビューをおこなうと、フィールドのラベルと入力データを簡易的に表示しています。このときの内容を出力するためのハンドラーを指定します。指定がない場合は、入力された値をそのまま利用します。
replace_handler
「replaceable」が 1 のとき、実際の価の置換処理をおこなうハンドラーを登録します。
search_handler
検索と置換、コンテンツデータ検索などの際に実際の検索処理をおこなうハンドラーを登録します。
list_props
コンテンツデータ一覧におけるフィールドの出力に関して設定します。詳しくはリスティングフレームワークの解説を御覧ください。今回は、値が特殊であるため一覧には表しません
options_html
コンテンツタイプ編集画面において、コンテンツフィールドの設定内容を表示するためのテンプレートを指定します。<.dd>
options
コンテンツフィールドのオプション情報として利用可能なものを列挙します。label, description, required, display については、必ず定義する必要があります。今回は、新しいオプションとして、データ編集画面で表示する際のラベルを指定するようにしました。

上記以外にも、いくつかの項目が存在しますが、今回は利用しません。詳しくは以下をご覧ください。

ss_validator
コンテンツデータ編集画面において、サーバーサイドの入力検証処理をおこなうハンドラーを登録します。必須チェックは共通で処理されるため記述する必要はありません。
site_data_import_handler
データインポートにおいて、特殊な処理をおこなう必要がある場合にハンドラーを指定します。
site_import_handler
データインポートにおいて、特殊な処理をおこなう必要がある場合にハンドラーを指定します。
theme_import_handler
テーマの適用時に特殊な処理を行う必要がある場合にハンドラーを指定します。
theme_export_handler
テーマのエクスポート時に特殊な処理を行う必要がある場合にハンドラーを指定します。
options_validation_handler
コンテンツタイプ編集画面において、入力されたオプションの値を検証するためのハンドラーを指定します。
options_pre_save_handler
コンテンツタイプ編集画面において、入力されたオプションの値を保存する直前に呼び出されるハンドラーを指定します。
search_class
他のオブジェクトへのリンクを保持するタイプのフィールドのとき、検索を実行するときに実際に検索する対象となるオブジェクトのクラス名を指定します。
search_columns
他のオブジェクトへのリンクを保持するタイプのフィールドのとき、検索を実行するときに実際に検索する対象となるオブジェクトのフィールド名を列挙します。

各種ハンドラーについて

登録するハンドラーについて、実際のコードを交えながら説明します。

data_load_handler

引数

$app
現在のアプリケーションオブジェクト
$field_data
コンテンツフィールドの情報

戻り値

取得したユーザーからの入力値を返す。

サンプルコード

sub data_load_handler {
    my ( $app, $field_data ) = @_;
    my $id   = $field_data->{id};

    my @titles = $app->multi_param('description-list-field-' . $field_data->{id} . '-title');
    my @values = $app->multi_param('description-list-field-' . $field_data->{id} . '-value');

    my @val;
    for (my $i = 0; $i < scalar @titles; $i++ ) {
        push @val, {
            title => $titles[$i],
            value => $values[$i]
        };
    }

    return \@val;
}

field_html_params

引数

$app
現在のアプリケーションオブジェクト
$field_data
コンテンツフィールドの情報

戻り値

変数名 => 値となるハッシュ値を返す。

サンプルコード

sub field_html_params {
    my ( $app, $field_data ) = @_;

    my $data = $field_data->{value};
    if (  !defined $data ) {
        push @$data, {
            title => '',
            value => ''
        }
    }
    my $required
        = $field_data->{options}{required} ? 'data-mt-required="1"' : '';

    {
        description_list_data => $data,
        required   => $required,
        field_label => $field_data->{options}{label},
    };
}

field_value_handler

引数

$ctx
テンプレートのビルドに利用されるコンテクストオブジェクト
$args
MTContentFieldValue タグで指定されたモディファイア
$cond
未使用
$field_data
コンテンツフィールドの情報
$value
コンテンツデータの値

戻り値

タグの処理結果 MTContentFieldValue タグには定義されていないモディファイアを利用することもできる。

サンプルコード

sub field_value_handler {
    my ( $ctx, $args, $cond, $field_data, $value ) = @_;
    my $part = lc $args->{part} || 'value';

    return if ($part ne 'title' and $part ne 'value' );

    return $value->{$part}
}

tag_handler

引数

$ctx
テンプレートのビルドに利用されるコンテクストオブジェクト
$args
MTContentFieldValue タグで指定されたモディファイア
$cond
未使用
$field_data
コンテンツフィールドの情報
$value
コンテンツデータの値

戻り値

内部ブロックの処理結果

サンプルコード

sub tag_handler {
    my ( $ctx, $args, $cond, $field_data, $value ) = @_;
    my $tok     = $ctx->stash('tokens');
    my $builder = $ctx->stash('builder');
    my $vars    = $ctx->{__stash}{vars} ||= {};
    my $out     = '';
    my $i       = 1;
    my $glue    = $args->{glue};

    for my $v ( @{$value} ) {
        local $vars->{__first__}   = $i == 1;
        local $vars->{__last__}    = $i == scalar @{$value};
        local $vars->{__odd__}     = ( $i % 2 ) == 1;
        local $vars->{__even__}    = ( $i % 2 ) == 0;
        local $vars->{__counter__} = $i;
        local $vars->{__value__}   = $v;

        defined(
            my $res = $builder->build(
                $ctx, $tok,
                {   %{$cond},
                    ContentFieldHeader => $i == 1,
                    ContentFieldFooter => $i == scalar @$value,
                }
            )
        ) or return $ctx->error( $builder->errstr );

        if ( $res ne '' ) {
            $out .= $glue
                if defined $glue && $i > 1 && length($out) && length($res);
            $out .= $res;
            $i++;
        }
    }

    $out;
}

feed_value_handler

引数

$app
現在のアプリケーションオブジェクト
$field_data
コンテンツフィールドの情報
$value
コンテンツデータの値

戻り値

フィード内の表示内容

サンプルコード

sub feed_value_handler {
    my ( $app, $field_data, $values ) = @_;

    return '' unless @$values;

    my $contents = '';
    for my $v (@$values) {
        my $encoded_v = MT::Util::encode_html($v->{value});
        my $encoded_t = MT::Util::encode_html( $v->{title} );
        $contents .= "<dt>$encoded_t</dt><dd>$encoded_v</dd>";
    }
    return "<dl>$contents</dl>";
}

preview_handler

引数

$app
現在のアプリケーションオブジェクト
$field_data
コンテンツフィールドの情報
$value
コンテンツデータの値

戻り値

プレビューの表示内容

サンプルコード

sub preview_handler {
    my ( $app, $field_data, $values ) = @_;

    unless ( ref $values eq 'ARRAY' ) {
        $values = [$values];
    }
    return '' unless @$values;

    my $contents = '';
    for my $v (@$values) {
        my $encoded_v = MT::Util::encode_html($v->{value});
        my $encoded_t = MT::Util::encode_html( $v->{title} );
        $contents .= "<dt>$encoded_t</dt><dd>$encoded_v</dd>";
    }
    return "<dl>$contents</dl>";
}

replace_handler

引数

$search_regex
検索内容の regex オブジェクト
$replace_string
置換内容
$field_data
コンテンツフィールドの情報
$value
コンテンツデータの値
$content_data
コンテンツデータオブジェクト

戻り値

置換対象が存在する場合 1 を返す。

サンプルコード

sub replace_handler {
    my ($search_regex, $replace_string, $field_data, $values,  $content_data ) = @_;
    return 0 unless defined $values;

    $values = [$values] unless ref $values eq 'ARRAY';

    my $replaced = 0;
    for (@$values) {
        $replaced += $_->{title} =~ s!$search_regex!$replace_string!g;
        $replaced += $_->{value} =~ s!$search_regex!$replace_string!g;
    }

    $replaced > 0;
}

search_handler

引数

$search_regex
検索内容の regex オブジェクト
$field_data
コンテンツフィールドの情報
$value
コンテンツデータの値
$content_data
コンテンツデータオブジェクト

戻り値

検索に該当するデータが存在する場合 1 を返す

サンプルコード

sub search_handler {
    my ( $search_regex, $field_data, $values, $content_data ) = @_;
    return 0 unless defined $values;

    $values = [$values] unless ref $values eq 'ARRAY';

    (
        grep {
            defined $_
                ? ( $_->{title} =~ /$search_regex/ ) || $_->{value} =~ /$search_regex/
                : 0
        } @$values
    ) ? 1 : 0;
}

必要なテンプレートについて

field_html

field_html で指定するテンプレートには、コンテンツデータ編集画面における個々のコンテンツフィールドの html を生成する役割があります。これは、新規作成時にデータを受け付ける方法や、既存データの表示を行なうことを意味します。

今回の例では、説明リストのタイトルと内容を入力するためのふたつのフィールドを用意します。また、複数の内容を登録できるようにするため、その組み合わせを任意に増やしたり減らす仕組みも用意します。また、順番を変更できるように並び替えについても実装しています。

サンプルコード

かなり長くなりますが、今回のテンプレートは以下のようになっています。
<div id="description-list-field-<mt:var name="content_field_id" escape="html">" class="description-list-field-container" <mt:var name="required">>
<mt:loop name="description_list_data">
  <div class="mt-draggable" draggable="true" aria-grabbed="false">
    <div class="col-auto">
      <mtapp:svgicon id="ic_move" title="Draggable">
    </div>
    <div class="col text-wrap">
      <div class="mt-flexBreak"></div>
	  <div class="mt-draggable__content description-list-data">
        <label><mt:var name="title_label" escape="html"></label>
	    <input type="text" class="form-control" name="description-list-field-<mt:var name="content_field_id" escape="html">-title" value="<mt:var name="title" escape="html">" mt:watch-change="1">
        <label><mt:var name="value_label" escape="html"></label>
        <textarea name="description-list-field-<mt:var name="content_field_id" escape="html">-value" class="form-control" rows="3" mt:watch-change="1"><mt:var name="value" escape="html"></textarea>
	  </div>
    </div>
    <div class="col-auto mr-3 d-none d-md-block">
      <a href="javascript:void(0)" class="remove">
        <__trans phrase="Remove">
      </a>
    </div>
  </div>
</mt:loop>
  <div class="mt-3">
    <a href="javascript:void(0)" class="add-description-list-button d-block d-md-inline-block" data-mt-content-field-id="<mt:var name="content_field_id">"><mtapp:svgicon id="ic_add" title="<__trans phrase="Add new pair">" size="sm"><__trans phrase="Add new [_1]" params="<mt:var name="field_label" escape="html" escape="html">"></a>
  </a>
  </div>
</div>

<mt:unless name="description-list-boilerplate">
  <mt:setvar name="description-list-boilerplate" value="1">
<div class="description-list-boilerplate" style="display: none;">
  <div class="mt-draggable" draggable="true" aria-grabbed="false">
    <div class="col-auto">
      <mtapp:svgicon id="ic_move" title="Draggable">
    </div>
    <div class="col text-wrap">
      <div class="mt-flexBreak"></div>
      <div class="mt-draggable__content description-list-data">
        <label><mt:var name="title_label" escape="html"></label>
	    <input type="text" class="form-control" value="" mt:watch-change="1">
        <label><mt:var name="value_label" escape="html"></label>
        <textarea class="form-control" rows="3" mt:watch-change="1"></textarea>
      </div>
    </div>
    <div class="col-auto mr-3 d-none d-md-block">
      <a href="javascript:void(0)" class="remove">
        <__trans phrase="Remove">
      </a>
    </div>
  </div>
</div>
</mt:unless>

<mt:unless name="setup_description_list_js">
<mt:var name="setup_description_list_js" value="1">

<mt:setvarblock name="jq_js_include" append="1">
function removeDescriptionList() {
  jQuery(this).parents('.mt-draggable').remove();
  setDirty(true);
  app.setDirty();
}

function addDescriptionList() {
  var $this = jQuery(this);
  var required = $this.data('mt-required') ? true : false;

  var $boilerPlate = jQuery('.description-list-boilerplate > div');
  var $newDiv = $boilerPlate.clone();

  $newDiv.find("input").prop('name', 'description-list-field-<mt:var name="content_field_id" escape="html">-title')
  $newDiv.find("textarea").prop('name', 'description-list-field-<mt:var name="content_field_id" escape="html">-value')

  $this.parent().before($newDiv.get(0));

  setDirty(true);
  (app.getIndirectMethod('setDirty'))();
}

window.descriptionListFieldSortableChanged = {};

jQuery('a.add-description-list-button').click(addDescriptionList)

jQuery('.description-list-field-container').on('click', 'a.remove', removeDescriptionList);

jQuery('#description-list-field-<mt:var name="content_field_id" escape="html">').sortable({
  items: 'div.mt-draggable[draggable=true][aria-grabbed]',
  handle: MT.Util.isMobileView() ? '.col-auto:first' : false,
  placeholder: 'placeholder',
  distance: 3,
  opacity: 0.8,
  cursor: 'move',
  forcePlaceholderSize: true,
  containment: '.mt-mainContent',
  start: function (event, ui) {
    ui.item.attr('aria-grabbed', true);

    if (window.descriptionListFieldSortableChanged[<mt:var name="content_field_id" escape="js">]) {
      ui.helper.offset({
        top: ui.helper.offset().top + jQuery('body').scrollTop()
      });
    }
  },
  sort: function (event, ui) {
    if (window.descriptionListFieldSortableChanged[<mt:var name="content_field_id" escape="js">]) {
      ui.helper.offset({
        top: ui.helper.offset().top + jQuery('body').scrollTop()
      });
    }
  },
  change: function (event, ui) {
    window.descriptionListFieldSortableChanged[<mt:var name="content_field_id" escape="js">] = true;
  },
  stop: function (event, ui) {
    ui.item.attr('aria-grabbed', false);
  }
});

jQuery.mtValidateAddRules({
  'div.description-list-field-container': function ($e) {
    var required = $e.data('mt-required') ? true : false;
    var selectedCount = $e.children('.mt-draggable').length;
    if ( required && selectedCount === 0 ) {
        this.error = true;
        this.errstr = trans('This field is required');
        return false;
    }
    return true;
  }
});

</mt:setvarblock>
</mt:unless>

options_html

options_html は、コンテンツフィルドのオプション設定について記述したテンプレートを用意します。type モディファイアにフィールドタイプのキー名を指定します。

サンプルコード

<mt:app:ContentFieldOptionGroup
   type="description_list">

  <mtapp:ContentFieldOption
     id="description-list-title-label"
     label="<__trans phrase="Title Label">">
    <input ref="title_label" type="text" name="title_label" id="description-list-title-label-fld" class="form-control w-25" value={ options.title_label || 0 }>
  </mtapp:ContentFieldOption>

  <mtapp:ContentFieldOption
     id="description-list-value-label"
     label="<__trans phrase="Value Label">">
    <input ref="value_label" type="text" name="value_label" id="description-list-value-label-fld" class="form-control w-25" value={ options.value_label || 0 }>
  </mtapp:ContentFieldOption>
  
</mt:app:ContentFieldOptionGroup>

独自に追加するオプションが存在しない場合でもテンプレートは用意する必要があります。

最低限のサンプルコード

<mt:app:ContentFieldOptionGroup
   type="description_list">

</mt:app:ContentFieldOptionGroup>

実際に使ってみる

今回作成したコンテンツフィールドを早速使ってみましょう。

コンテンツタイプの編集

コンテンツタイプを新規に作成して、今回作成したフィールドを追加してみましょう。今回のコンテンツタイプは、レシピ集です。(サンプルは伝統的にレシピとなっているとかなっていないとか)

フィールドを追加して、タイトルと説明のラベルを設定します。

recipe-edit-content-type.png

コンテンツデータを作成する

では、さっそくレシピを登録しましょう。

<コンテンツデータの編集画面を開くと、先程設定したラベルのペアを入力できるフィールドがあるのを確認できます。

recipe-edit-content-data.png

アーカイブテンプレートを作成する

折角なので、データを出力する方法にも触れておきましょう。

今回の説明リストの内容を出力するには、以下のようなテンプレートを記述します。

<h3>工程</h3>
<mt:ContentField  content_field="工程">
  <mt:ContentFieldHeader>
<dl>
  </mt:ContentFieldHeader>
  <dt><mt:ContentFieldValue part="title" escape="html"></dt>
  <dd><mt:ContentFieldValue part="value" escape="html"></dd>
  <mt:ContentFieldFooter>
</dl>
  </mt:ContentFieldFooter>
</mt:ContentField>

カスタムフィールドとは違い、個々のコンテンツフィールドについて新しいタグを覚える必要はありません。MTContentField タグでフィールドデータのコンテクストを作成し、MTContentFieldValue タグで値を出力する。新しく追加したフィールドタイプですが、他のフィールドと使い方は同じですね。

ポイントとしては、MTContentFieldValue タグに新しいモディファイアを追加していて、タイトルか説明かを指定しているという点です。これは「field_value_handler」のサンプルコードに書いてある内容ですね。

他のフィールドについてもテンプレートを記述して再構築をするとこんなページが出来上がります。 published-page.png

まとめ

コンテンツタイプのフィールドタイプは、標準で 20 種類用意されていますので、大体のことには対応ができると考えていますが、今回のサンプルで用意した説明リストや、Google Map の地図など、あると便利なフィールドタイプはまだまだあると思います。

Movable Type のコアやプラグインなどでも増やしていくことは検討しますが、プラグインがかける皆さまに於かれましては、ぜひ新しいフィールドタイプを作成し、是非プラグインディレクトリで公開してください。救われる人がいるかもしれません。

最後に

今年も無事に Movable Type Advent Calendar は完走です。いつも立ち上げてくれる MT 蝦夷ジャクスタポジション西山さん、ありがとうございます。感謝しています。

また、参加してくださった全国のコミュニティーメンバーの皆さん。ありがとうございました。

Movable Type Advanced、MT 7 のニューリリースを(切実に)心待ちにしてくださっている皆さん。年内と申し上げていながら年明けのリリースとなり、ご迷惑をおかけしております・・・。その分、しっかりとした品質のものに仕上げってきていると感じています。

現在、Movable Type 及び Movable Type Advanced のベータ版を公開しています。あのバグどうなったかな?あの機能はどうなる?など、いち早くご確認いただけます。ベータ版のフィードバックは年内いっぱい程度で一旦打ち切りとさせていただきますが、もし、あれ直ってないよ!というものがございましたら、フィードバックフォームよりお問い合わせください。

Movable Type 7 では、これからもの Content Hub Platform として利用できる CMS として成長を続けます。来年もまた Movable Type をよろしくお願いします。

では、良いお年を。

  • このエントリーをはてなブックマークに追加