GGn Formatters

Formatters

目前為 2025-07-17 提交的版本,檢視 最新版本

此腳本不應該直接安裝,它是一個供其他腳本使用的函式庫。欲使用本函式庫,請在腳本 metadata 寫上: // @require https://update.cn-greasyfork.org/scripts/540511/1625874/GGn%20Formatters.js

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         GGn Formatters
// @version      8
// @description  Formatters
// @author       ingts (some by ZeDoCaixao and letsclay)
// @match        https://gazellegames.net/
// ==/UserScript==

/**
 * @param {string} str
 * @param {string=} alias
 * @returns {string}
 */
function formatTitle(str, alias) {
    if (!str) return ''

    const japaneseLowercase = new Map([
        ["ga", ["が", "ガ"]],
        ["no", ["の", "ノ"]],
        ["wa", ["わ", "ワ"]],
        ["mo", ["も", "モ"]],
        ["kara", ["から", "カラ"]],
        ["made", ["まで", "マデ"]],
        ["to", ["と", "ト"]],
        ["ya", ["や", "ヤ"]],
        ["de", ["で", "デ"]],
        ["ni", ["に", "ニ"]],
        ["so", ["そ", "ソ"]],
        ["na", ["な", "ナ"]],
        ["i", ["い", "イ"]],
        ["u", ["う", "ウ"]],
        ["e", ["え", "エ"]],
        ["o", ["お", "オ"]],
        ["san", ["さん"]],
        ["sama", ["さま"]],
        ["kun", ["くん"]],
        ["chan", ["ちゃん"]]
    ])

    const smallWords = /^(a|an|and|as|at|but|by|en|for|if|in|nor|of|on|or|per|the|to|v.?|vs.?|via)$/i
    const alphanumericPattern = /([A-Za-z0-9\u00C0-\u00FF])/
    const wordSeparators = /([ :–—-]|[^a-zA-Z0-9'’])/
    const allUppercase = new Set(['rpg', 'fps', 'tps', 'rts', 'tbs', 'mmo', 'mmorpg', 'arpg', 'jrpg', 'pvp', 'pve', 'ntr', 'td', 'vr', 'npc', 'ost'])
    return str
        .replace(/\s/g, ' ')
        .replace(/ -(.*)- /, ': $1 ')
        .replace('—', ' - ')
        .replace(/ ?~$/, '')
        .replace(/-$/, '')
        .replace(/^-/, '')
        .replace(/ ~ ?/, ': ')
        .replace(/ - ?/, ': ')
        .replace(/[™®©]/g, '')
        .replace(' : ', ': ')
        .toLowerCase().trim()
        .split(wordSeparators)
        .map((current, index, array) => {
            const isFirstWord = index === 0
            const isLastWord = index === array.length - 1

            if (allUppercase.has(current.trim()) || /\b([IVX])(X{0,3}I{0,3}|X{0,2}VI{0,3}|X{0,2}I?[VX])(?![A-Za-z'])\b/i.test(current)) {
                return current.toUpperCase()
            }

            if (alias && !isFirstWord) {
                const jpWords = japaneseLowercase.get(current)
                if (jpWords?.some(w => alias.includes(w))) return current
            }

            if (
                /* Check for small words */
                current.search(smallWords) > -1 &&
                /* Ignore first and last word */
                !isFirstWord && !isLastWord &&
                /* Ignore title end and subtitle start */
                array[index - 3] !== ':' &&
                array[index + 1] !== ':' &&
                /* Ignore small words that start a hyphenated phrase */
                (array[index + 1] !== '-' ||
                    (array[index - 1] === '-' && array[index + 1] === '-'))
            ) {
                return current
            }
            /* Capitalize the first letter */
            return current.replace(alphanumericPattern, match => match.toUpperCase())
        })
        .join('')
}

let destructiveEditsEnabled = false

const headersMap = new Map([
    ["aboutGame", "[align=center][b][u]About the game[/u][/b][/align]\n"],
    ["features", "\n[align=center][b][u]Features[/u][/b][/align]"],
    ["sysReqs", "\n\n[quote][align=center][b][u]System Requirements[/u][/b][/align]\n"],
    ["minimumReqs", "\n[b]Minimum[/b]"],
    ["recommendedReqs", "\n[b]Recommended[/b]"],
    ["os", "\n[*][b]OS[/b]: "],
    ["processor", "\n[*][b]Processor[/b]: "],
    ["memory", "\n[*][b]Memory[/b]: "],
    ["storage", "\n[*][b]Storage[/b]: "],
    ["graphics", "\n[*][b]Graphics[/b]: "],
    ["soundcard", "\n[*][b]Sound Card[/b]: "],
    ["directX", "\n[*][b]DirectX[/b]: "],
    ["additionalnotes", "\n[*][b]Additional Notes[/b]: "],
    ["other", "\n[*][b]Other[/b]: "],
    ["network", "\n[*][b]Network[/b]: "],
    ["drive", "\n[*][b]Drive[/b]: "],
    ["controllers", "\n[*][b]Controllers[/b]: "],
])

/**
 * @param {string} str
 * @param {string=} gameTitle
 * @returns {string}
 */
function formatAbout(str, gameTitle) {
    if (!str) return ""

    const aboutHeader = headersMap.get("aboutGame")
    const aboutGameRegex = /^(\[size=3])?\n?(\[(b|u|i|align=center)]\n?){0,4}(About\sthe\sGame|About\sThis\sGame|What\sis\sThis\sGame\?|About|Description)\s*:?(\n?\[\/(b|u|i|align|size)]){0,4}(\s*:|:|)/i
    str = str.replace(aboutGameRegex, aboutHeader)

    let [_, about, reqs] = new RegExp(`(?:${RegExp.escape(aboutHeader)})?(.*?)(\\[quote].*|$)`, 's').exec(str)

    about = about.trim()
    about = about.replace(/^\[align=.*?](.*)\[\/align]$/s, '$1')
    about = about.replace(/defence/g, 'defense')
    about = about.replace(/ *\/ */g, '/')

    // bold game title. replace [u] or [i] with bold
    if (gameTitle) {
        const boldTitleRegex = new RegExp(`(\\[[uib]\])?${RegExp.escape(gameTitle)}(?:\\[\\/[ui]])?`, 'i')
        about = about.replace(boldTitleRegex, (match, p1) => {
            if (p1 === '[b]') return match
            return `[b]${gameTitle}[/b]`
        })
    }

    // If a line starts with [u], [i], or [b], there is no other text on that line, and it contains 'features', replace tags with [align=center][b][u]
    about = about.replace(/^(\[b]|\[u]|\[i])*(.*?)(\[\/b]|\[\/u]|\[\/i])*$/gm,
        (match, p1, p2, p3) => (p1 && p3 && /features/i.test(p2)) ? `[align=center][b][u]${p2}[/u][/b][/align]` : match)

    // Replace different list symbols with [*]
    about = about.replace(/^[-•◦■・★]\s*/gm, '[*]')

    // If a line starts with [u], [i], or [b], : and it is not the only text on that line, add [*] at the start and replace tags with [b]
    about = about.replace(/^(\[b]|\[u]|\[i])*(.*?)(\[\/b]|\[\/u]|\[\/i]):(.*$)/gm, (match, p1, p2, p3, p4) => {
        if (p4.trim() === '') {
            return match
        }
        return p1 && p3 ? `[*][b]${p2}[/b]${p4}` : match
    })

    // If a line starts with [*] followed by a [u] or [i], replace them with [b]
    about = about.replace(/^\[\*]\[[ui]](.*?)\[\/[ui]]/gm, '[b]$1[/b]')

    // If a line starts with [*], have only one new line until the next [*] and after the last one, have a double newline
    about = fixMultiLinesInLists(about)

    /*
        // Remove double newlines between [*] lines
        about = about.replace(/(\[\*][^\n]*)(\n{2,})(?=\[\*])/g, '$1\n')

        // Add a newline when next line doesn't start with [*]
        about = about.replace(/(\[\*][^\n]*\n)([^\[*\]\n])/g, '$1\n$2')
    */

    // If the line starts with [*] and the whole line until terminal punctuation is wrapped in [u], [i], or [b], remove the wrapping tags
    about = about.replace(/^\[\*]\[([bui])](.*?)\[\/([bui])]([.?!。?!])$/gm, "[*]$2$4")

    // If a line ends with [/align] replace double newlines with one newline
    about = about.replace(/(\[\/align])\n\n/g, '$1\n')

    // Remove colons in [align=center]
    about = about.replace(/\[align=center].*?(?:\[\/\w]:|:)\[\/align]/g,
        match => match.replace(/:/g, ''))

    if (destructiveEditsEnabled) {

        // If a line starts with [u], [i], or [b] and has only a new line after the closing tag, make it a list item
        about = about.replace(/\[[uib]](.*?)\[\/[uib]]:?\n(.*)/g, (_, p1, p2) => `[*][b]${p1}[/b]: ${p2}`)

        // Sentence case text inside list items
        about = about.replace(/\[\*]\[b](.*)\[\/b]/gm,
            (match, p1) => match.replace(p1, toSentenceCase(p1)))

        // Title case text inside [align=center][b][u]
        about = about.replace(/\[align=center]\[([bu])]\[([bu])]([\s\S]*?)\[\/\2]\[\/\1]\[\/align]/g,
            (match, p1, p2, p3) => `[align=center][b][u]${formatTitle(p3)}[/u][/b][/align]`)

        // Bold text before colon in list item
        about = about.replace(/\[\*](\w+): /g, '[*][b]$1[/b]: ')

        // If a line is all uppercase, title case it
        about = about.split('\n').map(line => line.toUpperCase() === line ? formatTitle(line) : line).join('\n')
    }

    about = about.replace(/\n*\s*\[\*]\s*/g, "\n[*]")

    const regFeatures = /\n(\[size=3])?\n?(\[(b|u|i|align=center|size=2)]\n?){0,4}(Key\sFeatures|Main\sFeatures|Game\sFeatures|Other\sFeatures|Features\sof\sthe\sGame|Features|Featuring|Feautures)\s*:?\s*(\n?\[\/(b|u|i|align|size)]){0,4}(\s*:|:|)/i
    const featuresHeader = headersMap.get("features")
    about = about.replace(regFeatures, featuresHeader)

    // Add features header
    about = about.replace(/(.*?)\n((?:\[\*].*\n?){3,})/g, (match, lineBefore, list) => {
        if (!lineBefore.includes('[/align]')) return (/^\s+$/.test(lineBefore) ? '': lineBefore + '\n')
            + (about.includes(featuresHeader) ? '' : featuresHeader) + '\n' + list
        return match
    })

    // Add a newline before lines with [align=center] if there isn't already a double newline before it. Here after adding features header
    about = about.replace(/(?<!\n\n)(\[align=center])/g, '\n\$1')

    about = about.replace(/\[\/align]\n*/gi, "[/align]\n")

    about = about.replace(/\n{2,10}/g, "\n\n")

    return aboutHeader + about + (reqs ? `\n\n${reqs}` : '')

    function fixMultiLinesInLists() {
        const lines = about.split('\n')
        const result = []
        let i = 0

        while (i < lines.length) {
            const line = lines[i]

            // If line starts with [*], we're in a list section
            if (line.startsWith('[*]')) {
                const listItems = []

                // Collect all consecutive [*] items (skipping empty lines between them)
                while (i < lines.length && (lines[i].startsWith('[*]') || lines[i].trim() === '')) {
                    if (lines[i].startsWith('[*]')) {
                        listItems.push(lines[i])
                    }
                    i++
                }

                // Add list items with single newlines between them
                result.push(...listItems)

                // Add single empty line after the list section if there's more content
                if (i < lines.length) {
                    result.push('') // This creates one empty line, which with the next line creates a double newline
                }
            } else {
                // Regular line, add it
                result.push(line)
                i++
            }
        }

        return result.join('\n')
    }
}

function toSentenceCase(str) {
    const pos = /]?\w/.exec(str)?.index // mainly to skip [*]
    if (pos === undefined) return

    let lowerStr = str.toLowerCase()
    const newStr = lowerStr.charAt(pos).toUpperCase() + lowerStr.slice(pos + 1)
    // Capitalise subsequent sentences
    return str.substring(0, pos) + newStr.replace(/([.?!\]]\s+)([a-z])/g, (match, p1, p2) => p1 + p2.toUpperCase())
}

/**
 * @param {string} str
 * @returns {string}
 */
function formatSysReqs(str) {
    if (!str) return ""
    const sysReqsHeader = headersMap.get("sysReqs")
    str = str.replace(/\n?^(\[size=3])?\n*(\[(b|u|i|quote|align=center|align=left)]\n?){0,5}\s*(System\sRequirements|Game\sSystem\sRequirements|Requirements|GOG\sSystem\sRequirements|Minimum\sSystem\sRequirements|System\sRequierments)\s*([:.])?\s*(\n?\[\/(b|u|i|align|size)]){0,5}(\s*:|:|)\n*(\n\[align=(left|center)])?/i,
        sysReqsHeader)

    let reqs = new RegExp(`${RegExp.escape(sysReqsHeader)}(.*)\\[\\/quote]`, 's').exec(str)?.[1]
    if (!reqs) return str
    const original = reqs

    reqs = reqs.replace(/:\n/g, "\n")
    reqs = reqs.replace(/:\[\/b]\n/g, "[/b]\n")

    reqs = reqs.replace(/(\d)\+/g, '$1')

    const osHeader = headersMap.get("os")
    const mobileOsMatch = /^(?:android|ios)? *\d.*/i.exec(reqs)?.[0]
    if (mobileOsMatch) {
        return str.replace(original, `${osHeader.replace('\n', '')}${mobileOsMatch}`)
    }

    reqs = reqs.replace(/intel/gi, 'Intel')
    reqs = reqs.replace(/amd/gi, 'AMD')
    reqs = reqs.replace(/\(?64.?bit\)?/g, "(64-bit)")

    const minimumHeader = headersMap.get("minimumReqs")
    const recommendedHeader = headersMap.get("recommendedReqs")

    //region Section labels formatting (mostly from Description Broom)

    // Minimum
    reqs = reqs.replace(/\n*(\[\*]\[([bi])]|((\s*|)\[\*])|\[([bi])]|\*|)(\s*|)(Minimum\sSpecifications|Minimum\sSystem\sRequirements|Minimum\sRequirements|Minimum)(\s|)(:\s\[\/([bi])]|:\[\/([bi])]|\[\/([bi])]:|\[\/([bi])]|:)/gi, minimumHeader)

    // Recommended
    reqs = reqs.replace(/\n*(\[\*]\[([bi])]|((\s*|)\[\*])|\[([bi])]|\*|)(\s*|)(Recommended\sSpecifications|Recommended\sSystem\sRequirements|Re(c|cc)o(mm|m)ended)(\s|)(:\s\[\/([bi])]|:\[\/([bi])]|\[\/([bi])]:|\[\/([bi])]|:)/gi, recommendedHeader)

    formatSectionLabel("Supported\\sOS|OS|Operating\\sSystems|Operating\\sSystem|Mac\\sOS|System|Mac", osHeader)
    formatSectionLabel("CPU\\sType|CPU\\sProcessor|CPU|Processor", headersMap.get("processor"))
    formatSectionLabel("System\\sRAM|RAM|System\\sMemory|Memory", headersMap.get("memory"))
    formatSectionLabel("Free\\sHard\\sDisk\\sSpace|Hard\\sDrive\\sSpace|Hard\\sDisk\\sSpace|Hard\\sDisk|Free\\sSpace|Hard\\sDrive|HDD\\sSpace|HDD|Storage|Disk\\sSpace|Free\\sDisk\\sSpace|Drive\\sSpace|Available\\sHard\\sDisk\\sSpace", headersMap.get("storage"))
    formatSectionLabel("VGA|Graphics|Graphic\\sCard|GPU|Video\\sCard|Video|GFX", headersMap.get("graphics"))
    formatSectionLabel("Sound\\sCard|Sound", headersMap.get("soundcard"))
    formatSectionLabel("DirectX\\sVersion|DirectX|Direct\\sX|DX", headersMap.get("directX"))
    formatSectionLabel("Additional\\sNotes|Additional|Notice|Please\\snote|Notes", headersMap.get("additionalnotes"))
    formatSectionLabel("Other\\sRequirements|Other|Peripherals", headersMap.get("other"))
    formatSectionLabel("Network|Internet", headersMap.get("network"))
    formatSectionLabel("(CD\\sDrive\\sSpeed|Disc\\sDrive|CD-ROM|DVD\\sDrive)", headersMap.get("drive"))
    formatSectionLabel("Controllers|Supported\\sJoysticks|Input", headersMap.get("controllers"))

    //endregion

    reqs = reqs.replace(/\n(.*)\n?\[\*]Requires a 64-bit.*\n?(.*)/g, (_, header, nextLine) => {
        /*
        Remove the whole section when it's like
        [b]Recommended[/b]
        [*]Requires a 64-bit processor and operating system[/quote] */
        if (!nextLine) return ''
        return `\n${header ? `${header}\n` : ''}${nextLine}` + (nextLine.includes('OS') && !nextLine.includes('64') ? ' (64-bit)' : '')
    })

    // has minimum but no recommended, replace the minimum with new line
    if (reqs.includes(minimumHeader) && !reqs.includes(recommendedHeader)) {
        reqs = reqs.replace(minimumHeader + '\n', "")
    }

    reqs = reqs.replace(/OS \*/g, 'OS')
    reqs = reqs.replace(/(\d+)\s?(\w)b/gi, (match, p1, p2) => `${p1} ${p2.toUpperCase()}B`)
    reqs = reqs.replace(/([a-zA-Z]{2,})(\d)/g, '$1 $2')
    reqs = reqs.replace(/,? *\(?or\)? *(?:better|greater|higher|over|more|later|newer|faster|similar|equal|equivalent)\)?/gi, '')


    // convert to next unit if divisible by 1024
    reqs = reqs.replace(/(\d+)\s*([KM]B)/gi, (match, num, unit) => {
        const intNum = parseInt(num)

        if (intNum % 1024 === 0) {
            return unit === 'KB' ? `${intNum / 1024} MB` : unit === 'MB' ? `${intNum / 1024} GB` : match
        }
        return match
    })

    formatSection('OS', match => {
        match = match.replace(/\(?:?32.*64.?bit\)? ?/gi, '') // both bits written, remove all
        match = match.replace(/^ *\(?64-bit\)?\s?(.*)/g, "$1 (64-bit)")
        match = match.replace(/(?:Microsoft\s?)?Win(?:dows)?/gi, 'Windows')
        match = match.replace(/macos/gi, "macOS")
        match = match.replace(/, /g, '/')

        // Remove repeated OS names
        let firstSkipped = false
        match = match.replace(/[a-zA-Z]+ /g, match => {
            if (!firstSkipped) {
                firstSkipped = true
                return match
            }
            return ''
        })

        return match
    })

    formatSection('Processor', match => {
        match = match.replace(/ or |, /gi, ' / ')
        match = match.replace(/ryzen/gi, 'Ryzen')
        match = match.replace(/core/gi, 'Core')
        match = match.replace(/core with (.*)Hz/gi, 'Core $1Hz')
        match = match.replace(/(.*?)-core/gi, '$1 Core')

        match = match.replace(/(\d\.?\d?)\s?(\w)hz/gi, (match, p1, p2) => `${p1} ${p2.toUpperCase()}Hz`)

        if (destructiveEditsEnabled) {
            match = match.replace(/(\w+-? *)Core (?:intel )?i(\d)/gi, (m, p1, p2) => {
                if (p1.toLowerCase().trim() !== 'intel') return `${p1} Core Intel Core i${p2}` // keep the cores quantifier e.g. Quad Core/4-Core
                return m
            })

            // if all Hz are the same, put it after a comma at the end
            const hzRegex = / \d\.?\d? \wHz/g
            const hzMatches = [...match.matchAll(hzRegex)]
            if (hzMatches.length > 1 && hzMatches.every(arr => arr[0] === hzMatches[0][0])) {
                for (const hzMatch of hzMatches) {
                    match = match.replace(hzMatch[0], '')
                }
                match = match + `, ${hzMatches[0][0]}`
            }
        }

        // too complicated to combine with the 1st one above
        match = match.replace(/(?:intel )?(?:core )?i(\d)(?: *- *(\d+)(\w)?)?/gi,
            (_, gen, model, sfx) => `Intel Core i${gen}${model ? `-${model}` : ''}${sfx ? sfx.toUpperCase() : ''}`)

        return match
    })

    formatSection('Graphics', match => {
        match = match.replace(/ or |, /gi, ' / ')
        if (destructiveEditsEnabled) {
            match = match.replace(/(?:nvidia )?(?:geforce )?([rg]tx) ?(\d+)/gi,
                (_, tx, num) => `Nvidia GeForce ${tx.toUpperCase()} ${num}`)

            match = match.replace(/(?:of )?(?:dedicated )?(?:(?<!intel hd )graphics |video )?/gi, '')
        }

        match = match.replace(/(?<!V)RAM|memory/, 'VRAM')
        match = match.replace(/graphics card with (.*?)\s?(?:of)? V?RAM/gi, '$1 VRAM')
        match = match.replace(/nvidia/gi, 'Nvidia')
        match = match.replace(/(?:amd )?radeon/gi, 'AMD Radeon')
        match = match.replace(/gpu/gi, 'GPU')
        match = match.replace(/(\d)Ti/gi, '$1 Ti')
        match = match.replace(/(?:series|video)?\s?card/gi, '')
        match = match.replace(/(\w)\/(\w)/g, '$1 / $2')
        return match
    })

    formatSection('DirectX', match => {
        match = match.replace(/v(?:ersion)?\s?/i, '')
        match = match.replace('.0', '')
        return match
    })

    reqs = reqs.replace(/(\d)\/([a-zA-Z])/g, '$1 / $2')
    reqs = reqs.replace(/(\S)\(/g, '$1 (')

    // remove duplicate Additional Notes
    let notes
    reqs = reqs.replace(/\n\[\*]\[b]Additional Notes\[\/b]:.*?$/gms, match => {
        if (!notes) {
            notes = match
            return match
        }
        return notes.toLowerCase() === match.toLowerCase() ? '' : match
    })

    return str.replace(original, reqs)

    function formatSectionLabel(partialPattern, replacement) {
        reqs = reqs.replace(
            // colon only, colon preceded/followed by [/b], colon with space before [/b]
            new RegExp(`\\n(?:\\[\\*]\\[b]|\\s*\\[\\*]|\\[b]|\\*)\\s*(?:${partialPattern})\\s*(?::\\s\\[\\/b]|:\\[\\/b]|\\[\\/b]:|:)\\s`, 'gi'),
            replacement)
    }

    /**
     * @param {string} sectionName
     * @param {(match: string) => string} func
     */
    function formatSection(sectionName, func) {
        const regExp = new RegExp(`^\\[\\*]\\[b]${sectionName}\\[\\/b]: (.*)`, 'gm')
        for (const match of reqs.matchAll(regExp)) {
            if (match?.[1]) {
                reqs = reqs.replace(match[1], func(match[1]))
            }
        }
    }
}

/**
 * @param {string} str
 * @returns {string}
 */
function formatDescCommon(str) {
    if (!str) return ""

    str = str.replace(/[™®©]/g, '')
    str = str.replace(/ ?\((?:[RC]|TM)\)/gi, '')
    str = str.replace(/([?!#$%;])(\w)/g, '$1 $2')
    str = str.replace(/([a-zA-Z]):(\w)/g, '$1: $2') // [a-zA-Z] to avoid aspect ratio
    str = str.replace(/(\w)[.,](\w)/g, (match, p1, p2) => {
        if (/\d/.test(p1) && /\d/.test(p2)) return match // dont add space for numbers, decimals
        return `${p1}. ${p2}`
    })

    // Move : and . outside of closing tags
    str = str.replace(/((?:\[[bui]])+)(.*?)([:.])((?:\[\/[bui]])+)/g, '$1$2$4$3')

    // region Description Broom stuff
    if (str.includes("[quote]") && !str.includes("[/quote]")) {
        str = str.replace("\n[/align]", "[/quote]")
        if (!str.includes("[/quote]")) {
            str = str + "[/quote]"
        }
    }

    str = str.replace(/\n{2,10}\[align=center]/g, "\n\n[align=center]")

    str = str.replace(/\n*\[quote]/g, "\n\n[quote]")

    str = str.replace(/\n*\[\/quote].*/gi, "[/quote]")

    //endregion

    return str
}

/**
 * @param {string} str
 * @param {string=} gameTitle
 * @returns {string}
 */
function formatAll(str, gameTitle) {
    str = formatDescCommon(str)
    str = formatAbout(str, gameTitle)
    str = formatSysReqs(str)
    return str
}

/**
 * @param {HTMLTextAreaElement} textarea
 * @param {string} unformattedDesc
 */
function createUnformattedArea(textarea, unformattedDesc) {
    let div = document.getElementById('unformatted-desc')
    if (div) return

    textarea.insertAdjacentHTML('beforebegin',
        `<div id="unformatted-desc">
    <button type="button" style="margin-bottom: 5px;"> Show unformatted description</button>
    <textarea cols="30" rows="15" style="filter: brightness(0.8); display:none; margin-bottom: 5px;margin-left: 0;width: 100%;" readonly>${unformattedDesc}</textarea>
</div>`)

    div = document.getElementById('unformatted-desc')
    const ta = div.querySelector('textarea')
    const btn = div.querySelector('button')
    btn.onclick = () => {
        if (ta.style.display === 'none') {
            ta.style.display = 'block'
            btn.textContent = 'Hide unformatted description'
        } else if (ta.style.display === 'block') {
            ta.style.display = 'none'
            btn.textContent = 'Show unformatted description'
        }
    }
}