利用者:悼む人/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年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))
})