注意: 保存後、変更を確認するにはブラウザーのキャッシュを消去する必要がある場合があります。

  • Firefox / Safari: Shift を押しながら 再読み込み をクリックするか、Ctrl-F5 または Ctrl-R を押してください (Mac では ⌘-R)
  • Google Chrome: Ctrl-Shift-R を押してください (Mac では ⌘-Shift-R)
  • Microsoft Edge: Ctrl を押しながら 最新の情報に更新 をクリックするか、Ctrl-F5 を押してください。
/* ここにあるすべてのJavaScriptは、すべてのページ読み込みですべての利用者に対して読み込まれます */
/**
 * MediaWiki 自動リンク(atwiki風)
 * SPDX-License-Identifier: 0BSD
 *
 * 記事本文中に既存ページ名が出現したら、自動で内部リンクに変換する。
 * - 適用: CONFIG.runNamespaces の名前空間の閲覧時、およびプレビュー時
 * - リンク対象: CONFIG.targetNamespaces で指定した名前空間のページ
 *   - 標準名前空間: タイトル全体を表示語にする
 *   - その他: 名前空間接頭辞を除き、サブページ有効なら最後の「/」以降を表示語にする
 * - キャッシュ: localStorage(mw.storage)。キーはウィキごと(wgWikiID)に分かれる。
 *   表示はキャッシュで即時に行い、更新確認は裏で間引いて実行し、
 *   変化があれば次回表示用に再取得する
 * - 除外: リンク・コード・見出し・編集リンク・脚注・目次・.noautolink 内、表示中のページ自身
 * - 英数字のみの語は3文字以上、それ以外は2文字以上。同一語はページ内で最大3回まで
 *
 * 設定の上書き:
 *   このスクリプトより前に window.AutoLinkConfig を定義すると、CONFIG の同名項目を上書きできる。
 *   例: window.AutoLinkConfig = { targetNamespaces: [0, 4, 'ヘルプ'], maxPerWord: 1 };
 * 部分的に止める:
 *   自動リンクさせたくない箇所は <div class="noautolink">…</div> などで囲む。
 */
(function () {
	'use strict';

	var CONFIG = $.extend({
		// 自動リンクを実行する名前空間(番号)
		runNamespaces: [0],
		// リンク対象の名前空間(番号または名前)。並び順が同じ表示語の優先順位になる
		// 例: [0, 4, 'ヘルプ'](存在しない名前は無視される)
		targetNamespaces: [0],
		// 構造を変えたときに上げると、古いキャッシュを使わなくなる
		cacheVersion: 1,
		// 以前の版で使っていたキャッシュキー(見つけたら削除する)
		oldCacheKeys: [],
		cacheMaxAge: 7 * 24 * 60 * 60, // 秒: 取得からこれを超えたキャッシュは使わない
		checkInterval: 10 * 60,        // 秒: 更新確認の最短間隔
		// 取得するページ名の上限。大規模なウィキでAPIとlocalStorageの負担を抑える
		maxTitles: 20000,
		maxPerWord: 3,
		minLengthAscii: 3,
		minLengthOther: 2,
		// true にすると、カタカナ語の途中への部分一致(例: 「データベース」内の「データ」)を避ける
		strictKatakana: false,
		excludeSelector: [
			'a', 'pre', 'code', 'kbd', 'samp', 'tt', 'var', 'textarea', 'script', 'style',
			'h1', 'h2', 'h3', 'h4', 'h5', 'h6', '.mw-heading', '.mw-editsection',
			'.reference', '.references', '.mw-references-wrap',
			'#toc', '.toc', '.navbox', '.mwe-math-element', '.catlinks', '.noautolink'
		],
		// ウィキ固有の除外対象を足すとき用(excludeSelector を丸ごと書き換えずに済む)
		extraExcludeSelector: []
	}, window.AutoLinkConfig || {});

	var action = mw.config.get('wgAction');
	var isView = action === 'view';
	var isPreview = action === 'edit' || action === 'submit';
	if (CONFIG.runNamespaces.indexOf(mw.config.get('wgNamespaceNumber')) === -1 ||
		(!isView && !isPreview)) {
		return; // 対象外のページではAPIも叩かない
	}

	var CACHE_KEY = 'autolink:' + (mw.config.get('wgWikiID') || 'wiki') + ':v' + CONFIG.cacheVersion;
	var EXCLUDE = CONFIG.excludeSelector.concat(CONFIG.extraExcludeSelector).join(',');

	var JA_CHARS = /[\u3040-\u30ff\u4e00-\u9fff]/;
	var ASCII_ONLY = /^[A-Za-z0-9 _\-()]+$/;
	var ALNUM = /[A-Za-z0-9_]/;
	var KATAKANA = /[\u30a1-\u30fa\u30fc]/;

	/* ---------- 名前空間・表示語 ---------- */

	function resolveNamespaces() {
		var ids = mw.config.get('wgNamespaceIds') || {};
		var out = [];
		CONFIG.targetNamespaces.forEach(function (ns) {
			var id = typeof ns === 'number' ?
				ns :
				ids[String(ns).toLowerCase().replace(/ /g, '_')];
			if (typeof id === 'number' && id >= 0 && out.indexOf(id) === -1) {
				out.push(id);
			}
		});
		return out;
	}

	function displayWord(title, ns, hasSubpages) {
		if (ns === 0) return title;
		var colon = title.indexOf(':');
		var name = colon !== -1 ? title.substring(colon + 1) : title;
		if (hasSubpages) {
			var slash = name.lastIndexOf('/');
			if (slash !== -1) name = name.substring(slash + 1);
		}
		return name;
	}

	function isLinkable(w) {
		if (!w || w === '__proto__') return false; // オブジェクトのキーとして扱えない語は除く
		if (!JA_CHARS.test(w) && ASCII_ONLY.test(w)) {
			return w.length >= CONFIG.minLengthAscii;
		}
		return w.length >= CONFIG.minLengthOther;
	}

	/* ---------- API取得 ---------- */

	// budget.left は全名前空間で共有する残り取得数
	function fetchNamespace(api, ns, budget) {
		var titles = [];
		function step(cont) {
			if (budget.left <= 0) {
				return $.Deferred().resolve(titles).promise();
			}
			var params = $.extend({
				action: 'query',
				list: 'allpages',
				apnamespace: ns,
				aplimit: 'max',
				formatversion: 2
			}, cont);
			return api.get(params).then(function (data) {
				var pages = (data.query && data.query.allpages) || [];
				for (var k = 0; k < pages.length && budget.left > 0; k++) {
					titles.push(pages[k].title);
					budget.left--;
				}
				if (data['continue'] && budget.left > 0) {
					return step(data['continue']);
				}
				return titles;
			}, function () {
				// 途中で失敗しても取得済み分で続行する
				return $.Deferred().resolve(titles).promise();
			});
		}
		return step({});
	}

	function buildMap(api, nsList) {
		return api.get({
			action: 'query',
			meta: 'siteinfo',
			siprop: 'namespaces',
			formatversion: 2
		}).then(function (info) {
			var nsInfo = (info.query && info.query.namespaces) || {};
			var budget = { left: CONFIG.maxTitles };
			var results = [];
			// 優先順位どおりに上限を割り当てるため、名前空間は順番に取得する
			var chain = $.Deferred().resolve().promise();
			nsList.forEach(function (ns, i) {
				chain = chain.then(function () {
					return fetchNamespace(api, ns, budget).then(function (titles) {
						results[i] = titles;
					});
				});
			});
			return chain.then(function () {
				var map = {};
				nsList.forEach(function (ns, i) {
					var hasSubpages = !!(nsInfo[ns] && nsInfo[ns].subpages);
					(results[i] || []).forEach(function (title) {
						var w = displayWord(title, ns, hasSubpages);
						if (!isLinkable(w) || Object.prototype.hasOwnProperty.call(map, w)) {
							return;
						}
						map[w] = title;
					});
				});
				return map;
			});
		});
	}

	// 対象名前空間での最新の作成・ログ(削除/移動など)のID
	function latestChangeId(api, nsList) {
		return api.get({
			action: 'query',
			list: 'recentchanges',
			rctype: ['new', 'log'],
			rcnamespace: nsList,
			rcprop: 'ids',
			rclimit: 1,
			formatversion: 2
		}).then(function (data) {
			var rc = data.query && data.query.recentchanges;
			return rc && rc.length ? rc[0].rcid : 0;
		});
	}

	/* ---------- キャッシュ ---------- */

	function saveCache(obj) {
		return mw.storage.setObject(CACHE_KEY, obj, CONFIG.cacheMaxAge);
	}

	// キャッシュの前提になる設定。変わったら取り直す
	function cacheSignature(nsList) {
		return [
			nsList.join(','),
			CONFIG.minLengthAscii,
			CONFIG.minLengthOther,
			CONFIG.maxTitles
		].join('|');
	}

	function refresh(api, nsList, sig, rcid) {
		return buildMap(api, nsList).then(function (map) {
			var now = Date.now();
			if (!saveCache({ sig: sig, map: map, rcid: rcid, fetched: now, checked: now })) {
				// 保存できない(容量超過など)場合、毎回の全件取得を避けるため
				// cacheMaxAge の間は自動リンクを止める。今回の表示だけは取得済みの一覧を使う
				saveCache({ sig: sig, failed: true, fetched: now, checked: now });
				mw.log.warn('[AutoLink] ページ名一覧をlocalStorageに保存できませんでした。' +
					'maxTitles を小さくするか、targetNamespaces を絞ってください。');
			}
			return map;
		}, function () {
			return {}; // 失敗時はキャッシュを作らず、今回はリンク化しない
		});
	}

	function getMap(api, nsList) {
		var sig = cacheSignature(nsList);
		var now = Date.now();
		var cached = mw.storage.getObject(CACHE_KEY);

		if (cached && cached.sig === sig && now - cached.fetched < CONFIG.cacheMaxAge * 1000) {
			if (cached.failed) {
				return $.Deferred().resolve({}).promise();
			}
			if (cached.map) {
				if (now - cached.checked > CONFIG.checkInterval * 1000) {
					cached.checked = now;
					saveCache(cached);
					latestChangeId(api, nsList).then(function (id) {
						if (id !== cached.rcid) {
							refresh(api, nsList, sig, id);
						}
					});
				}
				return $.Deferred().resolve(cached.map).promise();
			}
		}

		return latestChangeId(api, nsList).then(null, function () {
			return null; // 不明扱い。次回の確認で再取得される
		}).then(function (id) {
			return refresh(api, nsList, sig, id);
		});
	}

	/* ---------- 照合 ---------- */

	// 先頭文字ごとに語をまとめ、長い順に並べる(巨大な正規表現を避ける)
	function buildIndex(map, currentTitle) {
		var index = {};
		Object.keys(map).forEach(function (w) {
			if (map[w] === currentTitle) return; // 自己リンク防止
			var c = w.charAt(0);
			(index[c] = index[c] || []).push(w);
		});
		Object.keys(index).forEach(function (c) {
			index[c].sort(function (a, b) { return b.length - a.length; });
		});
		return index;
	}

	function boundaryOk(text, i, w) {
		var before = text.charAt(i - 1);
		var after = text.charAt(i + w.length);
		var first = w.charAt(0);
		var lastChar = w.charAt(w.length - 1);
		if (ALNUM.test(first) && ALNUM.test(before)) return false;
		if (ALNUM.test(lastChar) && ALNUM.test(after)) return false;
		if (CONFIG.strictKatakana) {
			if (KATAKANA.test(first) && KATAKANA.test(before)) return false;
			if (KATAKANA.test(lastChar) && KATAKANA.test(after)) return false;
		}
		return true;
	}

	function findMatches(text, index, counts) {
		var out = [];
		var i = 0;
		var len = text.length;
		while (i < len) {
			var list = index[text.charAt(i)];
			var hit = null;
			if (list) {
				for (var j = 0; j < list.length; j++) {
					var w = list[j];
					if (text.slice(i, i + w.length) === w && boundaryOk(text, i, w)) {
						hit = w;
						break;
					}
				}
			}
			if (hit) {
				// 上限に達した語は、短い語で部分リンクせずに読み飛ばす
				if ((counts[hit] || 0) < CONFIG.maxPerWord) {
					out.push({ index: i, word: hit });
					counts[hit] = (counts[hit] || 0) + 1;
				}
				i += hit.length;
			} else {
				i++;
			}
		}
		return out;
	}

	function linkify(root, ctx) {
		if (root.closest('[data-autolink-done]')) return;
		root.setAttribute('data-autolink-done', '1');

		var walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
			acceptNode: function (node) {
				var p = node.parentElement;
				if (!p || !/\S/.test(node.nodeValue) || p.closest(EXCLUDE)) {
					return NodeFilter.FILTER_REJECT;
				}
				return NodeFilter.FILTER_ACCEPT;
			}
		});
		var nodes = [];
		while (walker.nextNode()) nodes.push(walker.currentNode);

		var counts = {};
		nodes.forEach(function (node) {
			var text = node.nodeValue;
			var matches = findMatches(text, ctx.index, counts);
			if (!matches.length) return;

			var frag = document.createDocumentFragment();
			var last = 0;
			matches.forEach(function (m) {
				if (m.index > last) {
					frag.appendChild(document.createTextNode(text.slice(last, m.index)));
				}
				var title = ctx.map[m.word];
				var a = document.createElement('a');
				a.href = mw.util.getUrl(title);
				a.title = title;
				a.className = 'autolink';
				a.textContent = m.word;
				frag.appendChild(a);
				last = m.index + m.word.length;
			});
			if (last < text.length) {
				frag.appendChild(document.createTextNode(text.slice(last)));
			}
			node.parentNode.replaceChild(frag, node);
		});
	}

	/* ---------- 起動 ---------- */

	var readyPromise = null;
	function getReady() {
		if (!readyPromise) {
			readyPromise = mw.loader.using(['mediawiki.util', 'mediawiki.api', 'mediawiki.storage'])
				.then(function () {
					CONFIG.oldCacheKeys.forEach(function (k) { mw.storage.remove(k); });
					var nsList = resolveNamespaces();
					if (!nsList.length) return null;
					return getMap(new mw.Api(), nsList).then(function (map) {
						var current = (mw.config.get('wgPageName') || '').replace(/_/g, ' ');
						return { map: map, index: buildIndex(map, current) };
					});
				});
		}
		return readyPromise;
	}

	// 閲覧時はDOM readyを待たずに取得を開始する
	if (isView) getReady();

	mw.hook('wikipage.content').add(function ($content) {
		var $roots = $content.find('.mw-parser-output').addBack('.mw-parser-output');
		if (isPreview) {
			$roots = $roots.filter(function () {
				return $(this).closest('#wikiPreview').length > 0;
			});
		}
		if (!$roots.length) return;
		getReady().then(function (ctx) {
			if (!ctx) return;
			$roots.each(function () { linkify(this, ctx); });
		});
	});
}());