利用者:悼む人/TmpTree.js
< 利用者:悼む人
ナビゲーションに移動
検索に移動
注意: 保存後、変更を確認するにはブラウザーのキャッシュを消去する必要がある場合があります。
- Firefox / Safari: Shift を押しながら 再読み込み をクリックするか、Ctrl-F5 または Ctrl-R を押してください (Mac では ⌘-R)
- Google Chrome: Ctrl-Shift-R を押してください (Mac では ⌘-Shift-R)
- Internet Explorer / Microsoft Edge: Ctrl を押しながら 最新の情報に更新 をクリックするか、Ctrl-F5 を押してください
- Opera: Ctrl-F5を押してください
// テンプレート編集画面にて、テンプレートを構文解析、ツリー表示するスクリプト。
// Enpedia内での使い方:
// https://enpedia.org/w/index.php?title=利用者:<あなたのユーザ名>/common.js
// に飛び、編集→下記コードを貼り付けて保存。
// mw.loader.load("//enpedia.org/w/index.php?title=User:悼む人/TmpTree.js&action=raw&ctype=text/javascript");
// ・コードは全部人力で作成。
// ・構文解析には処理上限を設けてあるため、無限ループしません(多分)。
// ・正規表現が使われていないため、ReDoSの対象にはなりません。
// ・HTMLリンクを生成しないため、それ関連のXSSの対象にはなりません。
// 不具合報告は以下
// https://enpedia.org/w/index.php?title=利用者・トーク:悼む人&action=edit
$(() => {
// ver.2025年9月28日
if (!~location.href.indexOf(encodeURI('title=テンプレート:')) || !~location.href.indexOf('&action=edit')) return
console.log('MediaWiki Template Tree is now running!')
const d = document, PREFIX = 'TmpTree__', MAX_TOKEN = 2000
class Token {
constructor(name, l, sep, r, opt = {}) {
Object.assign(this, {name, l, sep, r}, opt)
}
}
class Tokens {
constructor(...tokenArgs) { this.v = tokenArgs }
getTokenByName(name) {
return this.v.find(token => token.name == name)
}
getSearchings(target) {
const searchingLs = this.v.map(token => new Searching(token, target))
return new Searchings(...searchingLs)
}
}
const basicTokens = new Tokens(
new Token('noinc', '<noinclude>', null, '</noinclude>', {noParseInner: true}),
new Token('nowiki', '<nowiki>', null, '</nowiki>', {noParseInner: true}),
new Token('attr', '{{{', '|', '}}}', {nxtName: true, color: '#911b00'}),
new Token('temp', '{{', '|', '}}', {nxtName: true, color: '#488200'}),
new Token('tbl', '\n{|', '\n|-', '\n|}', {color: '#5c5700'}),
new Token('wlin', '[[', '|', ']]', {color: '#00b3ff'}),
new Token('link', '[', ' ', ']', {color: '#7700ff'})
)
class Searching {
constructor(token, target) {
Object.assign(this, {token, target, entity: token[target]})
}
}
class Searchings {
constructor(...searchingTokenArgs) { this.v = searchingTokenArgs || [] }
get len() {return this.v.length}
concat(other) {
const concatedVals = this.v.concat(other.v)
return new Searchings(...concatedVals)
}
}
class AstNode {
constructor(token) {
const isRoot = token.name == 'root'
let [$box, $inside, $name, $end] = [
$(isRoot ? '<details>' : '<span>').addClass(`${PREFIX}box`),
$('<span>').addClass(`${PREFIX}inside`),
$('<span>').addClass(`${PREFIX}name`),
$('<span>').addClass(`${PREFIX}end ${PREFIX}not_end`).text(token.r)
]
if (token && token.color) $box.css('color', token.color)
const contents = token ? [d.createTextNode(token.l), $name, $inside, $end] : [$inside]
$box.append(...contents)
Object.assign(this, {token, parent: null, inside: [], $box, $inside, $name, $end, nxtSep: false, _name: ''})
}
get name() {return this._name}
set name(newName) {
this._name = newName
this.$name.text(newName)
}
appendAstNode(astNode) {
// 親子関係成立
astNode.parent = this
this.inside.push(astNode)
this.$inside.append(astNode.$box)
// 子がテキストノード1つだけなら、全ての子を折り返さない
if (this.inside.length > 1 || this.inside.some(node => !(node instanceof AstTxtNode))) {
this.$inside.addClass(`${PREFIX}inside_wrap`)
this.inside.forEach(node => node.$box.css({display: 'block'}))
}
// セパレート字句の追加が予め決まっていたら
if (this.nxtSep) {
// セパレート字句を親の色で追加
astNode.$box.prepend(
$('<span>').addClass(`${PREFIX}sep`).text(this.token.sep).css({
color: this.token && this.token.color || '#777'
})
)
this.nxtSep = false
}
return astNode
}
appendTxt(txt) {
if (txt.length < 1) return
this.appendAstNode(new AstTxtNode(this, txt))
}
// AstNodeが文法上閉じられたら呼び出す
close() {
// 最後にセパレート字句を追加する必要があったら
if (this.nxtSep) {
this.$inside.append(this.token.sep)
this.nxtSep = false
}
this.$end.removeClass(`${PREFIX}not_end`)
}
// セパレート字句と終了字句のSearchingsを返却
get backSearchings() {
if (this.token.name == 'root') return new Searchings()
const ls = [new Searching(this.token, 'r')]
if (this.token.sep) ls.push(new Searching(this.token, 'sep'))
return new Searchings(...ls)
}
}
class AstTxtNode {
constructor(parent, txt) {
this.parent = parent
this.$box = $('<span>').text(txt).addClass(`${PREFIX}txt`)
}
}
class Scans {
constructor() {
this.monoSearchings = new Searchings() // 排他
this.basicSearchings = basicTokens.getSearchings('l')
this.lowSearchings = new Searchings()
}
get searchings() {
if (this.monoSearchings.len > 0) return this.monoSearchings
return this.basicSearchings.concat(this.lowSearchings)
}
// source中に最初に現れる字句を取得
search(source, pos) {
let minIdx, appearSearching
for (const searching of this.searchings.v) {
const idx = source.indexOf(searching.entity, pos)
if (idx < 0) continue // 見つからない
if (typeof minIdx == 'undefined' || minIdx > idx) {
minIdx = idx
appearSearching = searching
}
}
return [minIdx, appearSearching]
}
}
const root = new AstNode(new Token('root', '', null, ''))
// 構文解析する関数
const parse = () => {
root.$inside.children().remove() // 前の解析結果を削除
let source = $('#wpTextbox1').val() || ''
if (!source.length) return
let curParent = root // 現在の親
const scans = new Scans()
let pos = 0, i
for (i = 1; i<=MAX_TOKEN; i++) { // 無限ループ防止
scans.lowSearchings = curParent.backSearchings
// 最初にマッチした字句を取得
const [appearIdx, appearSearching] = scans.search(source, pos)
//console.log(appearIdx, appearSearching)
// どの探すべき字句も現れなかったら、pos~txt.lengthまでをテキストとして扱う
if (!appearSearching) {
const txt = source.slice(pos, source.length)
curParent.appendTxt(txt)
break
}
// 発見字句までのテキストを処理
const beforeTxt = source.slice(pos, appearIdx)
if (beforeTxt.length) {
// テキストがcurParentの名前、かつ子要素がまだ無ければ、テキストをcurParentのnodeに名前として追加
if (curParent.token.nxtName && !curParent.name && curParent.inside.length < 1) curParent.name = beforeTxt
// そうでなければ普通にテキストノードとして追加
else curParent.appendTxt(beforeTxt)
}
// 発見字句を解析
switch (appearSearching.target) {
case 'l':
// <nowiki>を検知したら、次ターンは</nowiki>だけを検索させる
if (appearSearching.token.name == 'nowiki') {
scans.monoSearchings.v = new Searchings(basicTokens.getTokenByName('nowiki'), 'r')
}
curParent = curParent.appendAstNode(new AstNode(appearSearching.token))
break
case 'sep':
curParent.nxtSep = true
break
case 'r':
scans.lowSearchings = new Searchings()
curParent.close()
curParent = curParent.parent
break
}
// 次のcurParentがnullなら離脱
if (!curParent) break
// 次ループでは、さっき探した字句の後から探す
pos = appearIdx + appearSearching.entity.length
}
// 処理可能トークン数を超えたら
if (i >= MAX_TOKEN && curParent) {
root.$inside.append($('<div>').text(`解析可能なトークン数(${MAX_TOKEN})を超えました。`).css('color', 'red'))
}
}
let isParsedFirst = false
root.$inside.addClass(`${PREFIX}inside_root`)
root.$box.prepend($('<summary>').css({cursor: 'pointer'})
.text('構文解析結果を表示(元の内容と異なる場合があります/MediaWiki公式の構文解析結果とは異なる場合があります)'))
root.$box.append($('<button>').css({cursor: 'pointer'}).text('再解析').on('click', parse))
// detailsを最初にクリックしたときだけ構文解析
root.$box.on('click', () => {
if (isParsedFirst) return
parse()
isParsedFirst = true
})
$('#firstHeading').after(root.$box)
// スタイリング
const css = `
.${PREFIX}inside_root {
max-height: 80vh;
overflow: auto;
font-family: 'JetBrains Mono', 'Fira Code',
Consolas, 'biz UDGothic', 'MS Gothic',
'Osaka-Mono', Menlo, Monaco, 'Ubuntu Mono',
monospace, 'Courier New';
line-height: 1.1em;
font-size: .9em;
border: none !important;
&>:first-child { border-top: solid 1px #aaa; margin-top: .5rem; }
&>:last-child { border-bottom: solid 1px #aaa; margin-bottom: .5rem; }
}
.${PREFIX}inside_wrap {
display: block;
margin-left: .5em;
padding-left: .5em;
border-left: solid 1px #ccc;
}
.${PREFIX}txt { color: black; }
.${PREFIX}not_end { color: red; user-select: none; }`
$(d.head).append($('<style>').text(css))
})