利用者:悼む人/TmpTree.js

出典: 謎の百科事典もどき『エンペディア(Enpedia)』
ナビゲーションに移動 検索に移動

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

  • 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))
})