MediaWiki:Common.js
注意: 保存後、変更を確認するにはブラウザーのキャッシュを消去する必要がある場合があります。
- 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); });
});
});
}());