利用者:悼む人/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年12月14日
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
// 始めカッコから終わりカッコまでの塊1つを表す。
class Token {
// name: str // トークン名
// l: str // トークンの始めカッコ(開始字句)
// sep: str // トークン内の区切り文字(セパレート字句)
// r: str // トークンの終わりカッコ(終了字句)
constructor(name, l, sep, r, opt = {}) {
Object.assign(this, { name, l, sep, r }, opt)
}
}
// 複数のTokenを抱える。
class TokenLs {
constructor(...tokenArgs) {
this.v = tokenArgs
}
// トークン名にマッチする最初のTokenを取得
getTokenByName(name) {
return this.v.find(token => token.name == name)
}
// 指定したtarget: "l" | "sep" | "r" から検索中トークン(=Searching)を生成
getSearchingLs(target) {
const searchingList = this.v.map(token => new Searching(token, target))
return new SearchingLs(...searchingList)
}
}
// MediaWikiテンプレートページで使われる記法を、Tokenの集合で表現
// 第5引数のoptのプロパティの説明
// nxtName: bool // 始めカッコのすぐ右にテンプレート名などの表記が来るか
// color: str // 字句の色(構文ハイライト、16進数カラーコード)
const basicTokens = new TokenLs(
new Token('noinc', '<noinclude>', null, '</noinclude>'),
new Token('nowiki', '<nowiki>', null, '</nowiki>'),
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' })
)
// 検索中トークン。字句解析中に探している字句1つを表す。
class Searching {
constructor(token, target) {
Object.assign(this, { token, target, entity: token[target] })
}
}
// 複数の検索中トークンを抱える。
class SearchingLs {
constructor(...searchingTokenArgs) {
this.v = searchingTokenArgs || []
}
get len() {
return this.v.length
}
// 別のSearchingsと合体した新たなSearchingsを返す
concat(other) {
const concatedVals = this.v.concat(other.v)
return new SearchingLs(...concatedVals)
}
}
// 構文解析木のノード単体を表す。つまり、AstNode同士を組み合わせて木構造を作れる。
// 「$」が頭に付く変数はjQueryオブジェクトを格納
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)
// HTML表示部を作成
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: '' })
}
// ASTノードの名前
get name() {
return this._name
}
set name(newName) {
this._name = newName
this.$name.text(newName)
}
// 配下に引数astNodeを子として追加
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
}
// このAstNodeの配下に、引数txtから作ったテキストノードを子として追加
appendTxt(txt) {
if (txt.length < 1) return
this.appendAstNode(new AstTxtNode(this, txt))
}
// ASTノードが文法上閉じられたら呼び出す
close() {
// 最後にセパレート字句を追加する必要があったら
if (this.nxtSep) {
this.$inside.append(this.token.sep)
this.nxtSep = false
}
this.$end.removeClass(`${PREFIX}not_end`)
}
// このASTNodeのセパレート字句と終了字句を検索対象とするSearchingsを返却
get backSearchingLs() {
if (this.token.name == 'root') return new SearchingLs()
const ls = [new Searching(this.token, 'r')]
if (this.token.sep) ls.push(new Searching(this.token, 'sep'))
return new SearchingLs(...ls)
}
}
// 構文解析木のテキストノード単体。
class AstTxtNode {
constructor(parent, txt) {
this.parent = parent
this.$box = $('<span>').text(txt).addClass(`${PREFIX}txt`)
}
}
// 使うSearchingsを切り換えながら字句を検索し、構文解析するための、Searchings管理クラス。
class Scans {
constructor() {
// 排他。ここに検索対象が1つでもあったら、下記のSearchingsを使わせない。
this.monoSearchingLs = new SearchingLs()
// MediaWikiテンプレートページで使われる記法の、開始字句しか含まないSearchings。
this.basicSearchingLs = basicTokens.getSearchingLs('l')
this.lowSearchingLs = new SearchingLs() // 最も優先度の低いSearchings。
}
// Searchingsの切り換えを実現
get searchingLs() {
if (this.monoSearchingLs.len > 0) return this.monoSearchingLs
return this.basicSearchingLs.concat(this.lowSearchingLs)
}
// source中に最初に現れる字句を取得
search(source, pos) {
let minIdx, appearSearching
for (const searching of this.searchingLs.v) {
const idx = source.indexOf(searching.entity, pos)
if (idx < 0) continue // 見つからない
// 複数Searchingのうち、文中にて最も前方に現れたSearchingを得る。
if (typeof minIdx == 'undefined' || minIdx > idx) {
minIdx = idx
appearSearching = searching
}
}
return [minIdx, appearSearching]
}
}
// 最上位のASTノード(木の根っこ)。
// これに子ASTノードを次々生やしていき、構文解析木を構築する。
const root = new AstNode(new Token('root', '', null, ''))
// 実際に構文解析を行う関数。
const parse = () => {
root.$inside.children().remove() // 前の解析結果のHTML表示を削除
let source = $('#wpTextbox1').val() || ''
if (!source.length) return
let curParent = root // 現在の親ASTノード
const scans = new Scans()
let pos = 0
let i = 1
// 無限ループ防止のため、MAX_TOKEN回以上繰り返さない
for (i = 1; i <= MAX_TOKEN; i++) {
// 前回ループ時に発見した開始字句に対応するセパレート字句と終了字句、
// を検索対象とするSearchingsを、最も優先度の低いSearchingsとして登録。
scans.lowSearchingLs = curParent.backSearchingLs
// 最初に発見したSearchingを取得
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.monoSearchingLs.v = new SearchingLs(basicTokens.getTokenByName('nowiki'), 'r')
}
// 次ループ時の親ASTノードは、開始字句のASTノード
curParent = curParent.appendAstNode(new AstNode(appearSearching.token))
break
// セパレート字句なら
case 'sep':
// セパレート字句追加モードを開始。
// 次ループ時のappendTxtやappendAstNode時に、セパレート字句が実際に構文木に登録される。
curParent.nxtSep = true
break
// 終了字句なら
case 'r':
scans.lowSearchingLs = new SearchingLs() // 空に
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'))
}
}
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を最初にクリックしたときだけ構文解析
let isParsedFirst = false
root.$box.on('click', e => {
if (root.$box[0] !== e.currentTarget) return // detailsの子を押した場合は除外
if (isParsedFirst) return
parse()
isParsedFirst = true
})
$('#firstHeading').after(root.$box)
// スタイリング
const css = `
.${PREFIX}inside_root {
max-height: 80vh;
overflow: auto;
font-family: 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))
})