Movable Typeで記事検索UIを作る方法|jsonを使った実装手順

Movable Typeで記事検索を実装する場合、従来のテンプレートベースの検索やサーバーサイド検索では、表示速度や柔軟性の面で物足りなさを感じることがあります。

特に、カテゴリ・タグ・キーワードを組み合わせた絞り込みや、使いやすい検索UIを作りたい場合、標準機能だけでは設計が窮屈になりやすい場面があります。

そこでおすすめなのが、記事データをJSONとして出力し、JavaScriptで検索を行う構成です。この方法なら、高速で柔軟な検索UIを実現しやすく、UIや検索条件の設計もかなり自由になります。

今回は、articles.jsonを使った記事検索の実装方法を、できるだけわかりやすく整理しながらご紹介します。コードはあえて機能ごとに分け、後から調整しやすい構成にしています。

なぜJSONで記事検索を行うのか?

Movable Typeの標準的な検索機能でも記事検索は可能ですが、運用や見た目まで含めて考えると、次のような悩みが出てくることがあります。

  • 検索結果の表示速度が気になる
  • カテゴリやタグを組み合わせた検索が作りにくい
  • 検索フォームや結果一覧のUIを自由に設計しづらい
  • ちょっとした仕様変更でもテンプレートが複雑になりやすい

一方で、記事データをJSONとして出力し、フロント側で検索を行う構成にすると、次のようなメリットがあります。

  • 高速表示:検索処理の多くをブラウザ側で完結できる
  • 柔軟な検索条件:カテゴリ、タグ、キーワードなどを自由に組み合わせやすい
  • UIの自由度が高い:検索フォームや一覧表示をデザインしやすい
  • 機能追加しやすい:あとからOR検索やページネーションも足しやすい

特に、記事数が数百件規模になってくると、こうしたメリットがかなり効いてきます。単に「検索できる」だけでなく、探しやすい・直しやすい・育てやすい検索UIにしやすいのが大きな魅力です。

今回の構成

今回の実装は、大きく分けると次の3段階です。

  • Movable Typeで articles.json を生成する
  • HTMLで検索UIの土台を用意する
  • JavaScriptで絞り込みと表示を行う

やっていること自体はシンプルですが、この構成にしておくと、あとで検索対象を増やしたり、表示の仕方を変えたりしやすくなります。

STEP1:articles.json を生成する

まずは、記事データをJSONとして出力するためのインデックステンプレートを作成します。ここで大事なのは、タイトルや概要文を安全にJSON化することと、タグ配列や末尾カンマを崩さないことです。

<mt:Entries lastn="1000" sort_by="authored_on" sort_order="desc">
<mt:EntriesHeader>[
</mt:EntriesHeader>
{
  "id": "<$mt:EntryID$>",
  "title": "<$mt:EntryTitle encode_json="1"$>",
  "date": "<$mt:EntryDate format="%Y-%m-%d"$>",
  "category": "<mt:EntryPrimaryCategory><$mt:CategoryLabel encode_json="1"$></mt:EntryPrimaryCategory>",
  "tags": [<mt:EntryTags>"<$mt:TagName encode_json="1"$>"<mt:Unless name="__last__">,</mt:Unless></mt:EntryTags>],
  "excerpt": "<$mt:EntryExcerpt encode_json="1"$>",
  "link": "<$mt:EntryPermalink$>"
}<mt:Unless name="__last__">,</mt:Unless>
<mt:EntriesFooter>
]
</mt:EntriesFooter>
</mt:Entries>

このテンプレートで、記事ごとのID、タイトル、日付、カテゴリ、タグ、概要文、URLをまとめて出力できます。

encode_json="1" を付けているのは、引用符や改行などが混ざってもJSONが壊れにくくするためです。また、__last__ を使って末尾カンマを制御しておくと、JSONパースエラーを防ぎやすくなります。

記事数が500件前後であれば、lastn="1000" くらいにしておけば実用上は十分です。将来的に記事が増えても余裕を持って運用しやすくなります。

STEP2:検索UIのHTMLを用意する

次に、検索フォームと検索結果の表示エリアを用意します。ここでは必要最小限の構成にしておき、カテゴリの選択肢はJavaScript側で自動生成する前提にします。

<div class="search-ui">
  <input type="text" id="searchInput" placeholder="キーワード検索">
  <input type="text" id="tagInput" placeholder="タグ(カンマ区切り)※OR検索">

  <select id="categorySelect">
    <option value="">カテゴリ(すべて)</option>
  </select>

  <button type="button" id="searchBtn">検索</button>
  <button type="button" id="resetBtn">リセット</button>
</div>

<div id="meta"></div>
<div id="list"></div>
<div id="pagination"></div>

この段階では、フォームと結果表示の置き場所だけ用意できれば十分です。カテゴリをHTMLに手書きしてしまうと、運用中にカテゴリが増えたときに修正漏れが起こりやすくなるため、JSONから自動生成する方が管理しやすくなります。

STEP3:JavaScriptは機能ごとに分けて考える

今回のポイントは、JavaScriptを1本の長いコードにするのではなく、役割ごとに分けて整理することです。

ただし、この記事の主題は「コードの分割テクニック」そのものではなく、あくまでJSON検索によって高速で柔軟な検索を実現することです。そのうえで、分かりやすく保守しやすい書き方として、関数を分けていきます。

1. まずは全体の土台を用意する

最初に、JSONのURLやページあたりの件数、検索対象データを保持する変数などをまとめておきます。ここは実装全体の土台になる部分です。

const JSON_URL = '/articles.json';
const perPage = 10;

let allData = [];
let filtered = [];
let currentPage = 1;

const $ = (id) => document.getElementById(id);

allData は元の全件データ、filtered は絞り込み後のデータ、currentPage は現在ページを管理する変数です。最初にこうした役割を分けておくと、後の処理がかなり読みやすくなります。

2. 文字列を整える関数を用意する

検索では、大文字小文字の違いや余分な空白、概要文に混ざるHTMLなどを吸収したい場面があります。そこで、文字列の整形は専用関数としてまとめておくのがおすすめです。

function normalize(str) {
  return (str ?? '').toString().trim().toLowerCase();
}

function splitTags(input) {
  return normalize(input)
    .split(',')
    .map(s => s.trim())
    .filter(Boolean);
}

function stripHtml(html) {
  const tmp = document.createElement('div');
  tmp.innerHTML = html ?? '';
  return (tmp.textContent || tmp.innerText || '').trim();
}

normalize は検索用に文字列を揃える関数、splitTags は「A,B」のようなタグ入力を分割する関数、stripHtml は概要文からHTMLを取り除く関数です。

こうした前準備をきちんと分けておくと、絞り込み本体のロジックをすっきり書けるようになります。

3. 検索しやすい形に前処理する

JSONを読み込むたびに毎回整形するのではなく、最初に検索用データを作っておくと、検索処理がシンプルになります。

function preprocess(items) {
  return items.map(item => {
    const title = item.title ?? '';
    const excerpt = stripHtml(item.excerpt ?? '');
    const category = item.category ?? '';
    const tags = Array.isArray(item.tags) ? item.tags : [];

    return {
      ...item,
      excerpt,
      _titleN: normalize(title),
      _excerptN: normalize(excerpt),
      _categoryN: normalize(category),
      _tagsN: tags.map(tag => normalize(tag))
    };
  });
}

ここでは、元のデータに対して検索用のフィールドを追加しています。たとえば、_titleN_excerptN は正規化済みの検索用文字列です。

このやり方にしておくと、絞り込み時には「整形」と「判定」を分けて考えられるので、速度面だけでなく可読性の面でもメリットがあります。

4. カテゴリ一覧を自動生成する

カテゴリの選択肢をJSONから生成しておけば、新しいカテゴリが追加されてもフォーム側の修正が不要になります。

function buildCategoryOptions(items) {
  const select = $('categorySelect');
  const set = new Set();

  items.forEach(item => {
    const category = (item.category ?? '').toString().trim();
    if (category) set.add(category);
  });

  const categories = Array.from(set).sort((a, b) => a.localeCompare(b, 'ja'));

  categories.forEach(category => {
    const option = document.createElement('option');
    option.value = category;
    option.textContent = category;
    select.appendChild(option);
  });
}

Set を使って重複を除き、最後に並び順を整えてから <option> を追加しています。こうしておくと、運用側がカテゴリを増やしても検索UIが自然に追従できます。

5. 絞り込み処理を独立させる

検索UIの中心になるのがこの部分です。キーワード、カテゴリ、タグの条件をそれぞれ分けて判定し、最後にまとめています。

function applyFilter() {
  const keyword = normalize($('searchInput').value);
  const category = normalize($('categorySelect').value);
  const tags = splitTags($('tagInput').value);

  filtered = allData.filter(item => {
    const matchKeyword =
      !keyword ||
      item._titleN.includes(keyword) ||
      item._excerptN.includes(keyword);

    const matchCategory =
      !category || item._categoryN === category;

    const matchTags =
      tags.length === 0 ||
      tags.some(tag => item._tagsN.includes(tag));

    return matchKeyword && matchCategory && matchTags;
  });

  currentPage = 1;
  render();
}

ここでは、タグをOR条件で判定しています。つまり、「A,B」と入力したときに、AまたはBを含む記事がヒットする形です。

タグをAND条件にする設計もありますが、一般的な記事検索では、まずはOR条件の方が使いやすいケースが多い印象です。あとから条件の変え方も調整しやすいのが、JSON検索の良いところです。

6. 一覧表示の処理を分ける

絞り込み結果をどう表示するかも、別関数にしておくと管理しやすくなります。検索ロジックと描画ロジックを分けるのは、保守性の面でもかなり重要です。

function clearNode(node) {
  while (node.firstChild) node.removeChild(node.firstChild);
}

function truncate(str, max = 140) {
  if (!str) return '';
  return str.length > max ? str.slice(0, max) + '...' : str;
}

function renderList() {
  const list = $('list');
  clearNode(list);

  const start = (currentPage - 1) * perPage;
  const pageItems = filtered.slice(start, start + perPage);

  if (pageItems.length === 0) {
    list.textContent = '該当する記事が見つかりませんでした。';
    return;
  }

  const frag = document.createDocumentFragment();

  pageItems.forEach(item => {
    const wrap = document.createElement('div');

    const title = document.createElement('h3');
    const link = document.createElement('a');
    link.href = item.link;
    link.textContent = item.title;
    title.appendChild(link);

    const meta = document.createElement('p');
    meta.textContent = `${item.date} / ${item.category || 'カテゴリなし'}`;

    const excerpt = document.createElement('p');
    excerpt.textContent = truncate(item.excerpt, 160);

    wrap.appendChild(title);
    wrap.appendChild(meta);
    wrap.appendChild(excerpt);

    frag.appendChild(wrap);
  });

  list.appendChild(frag);
}

この関数は「一覧をどう見せるか」に専念しています。記事タイトル、日付、カテゴリ、概要を並べるだけでも十分使いやすいですが、ここにタグ表示やサムネイル表示を足していくこともできます。

検索結果が0件だったときの表示も、この関数にまとめておくと挙動がわかりやすくなります。

7. ページネーションを分ける

記事数が増えてくると、検索結果を一度に全部表示するよりも、ページ分けした方が見やすくなります。まずは基本形として、シンプルなページネーションを用意します。

function getTotalPages() {
  return Math.max(1, Math.ceil(filtered.length / perPage));
}

function renderMeta() {
  const totalPages = getTotalPages();
  $('meta').textContent = `該当 ${filtered.length} 件 / ${currentPage} / ${totalPages} ページ`;
}

function makeBtn(label, onClick, disabled = false) {
  const btn = document.createElement('button');
  btn.type = 'button';
  btn.textContent = label;
  btn.disabled = disabled;
  btn.addEventListener('click', onClick);
  return btn;
}

function renderPagination() {
  const pagination = $('pagination');
  clearNode(pagination);

  const totalPages = getTotalPages();
  if (totalPages <= 1) return;

  pagination.appendChild(
    makeBtn('前へ', () => {
      currentPage--;
      render();
    }, currentPage === 1)
  );

  for (let page = 1; page <= totalPages; page++) {
    pagination.appendChild(
      makeBtn(String(page), () => {
        currentPage = page;
        render();
      }, page === currentPage)
    );
  }

  pagination.appendChild(
    makeBtn('次へ', () => {
      currentPage++;
      render();
    }, currentPage === totalPages)
  );
}

この実装はあえてシンプルにしています。記事数がさらに増えてきたら、省略記号つきのページネーションや「前後数ページだけ表示する」方式に発展させることもできます。

ただ、まずはこの基本形を押さえておくと、検索結果表示の流れがかなり理解しやすくなります。

8. 最後にまとめて初期化する

ここまで作った各機能を、最後に renderinit でつなぎます。イベント登録もここでまとめて行います。

function render() {
  renderMeta();
  renderList();
  renderPagination();
}

function bindEvents() {
  $('searchBtn').addEventListener('click', applyFilter);

  $('resetBtn').addEventListener('click', () => {
    $('searchInput').value = '';
    $('tagInput').value = '';
    $('categorySelect').value = '';
    filtered = allData;
    currentPage = 1;
    render();
  });

  $('searchInput').addEventListener('input', applyFilter);
  $('tagInput').addEventListener('input', applyFilter);
  $('categorySelect').addEventListener('change', applyFilter);
}

async function init() {
  try {
    const res = await fetch(JSON_URL, { cache: 'no-cache' });
    if (!res.ok) throw new Error('JSON fetch failed');

    const data = await res.json();

    allData = preprocess(Array.isArray(data) ? data : []);
    filtered = allData;

    buildCategoryOptions(allData);
    bindEvents();
    render();

  } catch (e) {
    $('meta').textContent = '記事データの読み込みに失敗しました。';
    console.error(e);
  }
}

init();

ここでは、JSONの読み込み、前処理、カテゴリ生成、イベント登録、初回描画までを順番につないでいます。最終的にこの流れにしておくと、「何をいつやっているのか」がかなり把握しやすくなります。

実装上のポイント

1. まずはシンプルに始める

JSON検索は柔軟に拡張できますが、最初から機能を盛り込みすぎると、かえって管理しづらくなります。まずは、キーワード検索、カテゴリ、タグ、ページネーションくらいから始めるのがちょうどよいと思います。

2. 検索と表示を分ける

検索条件の判定と、画面への描画を別関数にしておくと、あとから仕様変更するときにかなり助かります。見た目を変えたいだけなのに検索ロジックまで触る、といった状況を避けやすくなります。

3. 運用を見据えて設計する

カテゴリの自動生成や、前処理による検索用データの整形は、地味ですがかなり効きます。記事数が少ないうちは差を感じにくくても、数が増えてくると運用のしやすさに大きな差が出てきます。

まとめ:Movable Typeの検索はJSON化すると高速で柔軟になる

Movable Typeで記事検索を実装するなら、記事データをJSONとして出力し、JavaScriptで検索を行う構成にすることで、高速で柔軟な検索UIを作りやすくなります。

この方法の良いところは、単に表示が速くなるだけではありません。カテゴリやタグを組み合わせた絞り込み使いやすい検索フォームあとから機能を足しやすい設計まで含めて、かなり扱いやすい仕組みにできる点にあります。

特に、記事数が増えてきたサイトや、検索UIをもっと使いやすくしたいサイトでは、この構成が非常に相性が良いと思います。まずはシンプルなJSON検索から始めて、必要に応じてOR検索の強化やページネーションの改善などを加えていくのがおすすめです。

アカルまでお気軽にご相談ください

アカル株式会社では、Movable Typeのテンプレート設計だけでなく、今回のようなJSONを使った記事検索UIの実装や、高速で運用しやすい構成への整理も得意としております。

「MTで検索をもっと使いやすくしたい」「記事数が増えてきたので探しやすくしたい」といったご相談がありましたら、ぜひお気軽にお問い合わせください。

前へ

Movable Typeで「よくある質問(FAQ)」コンテンツを構築する方法