MetadataParser

A solution to parse user-script metadata blocks.

Този скрипт не може да бъде инсталиран директно. Това е библиотека за други скриптове и може да бъде използвана с мета-директива // @require https://update.greasyfork.org/scripts/566592/1756806/MetadataParser.js

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey, Greasemonkey или Violentmonkey.

За да инсталирате този скрипт, трябва да инсталирате разширение, като например Tampermonkey .

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey или Violentmonkey.

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey или Userscripts.

За да инсталирате скрипта, трябва да инсталирате разширение като Tampermonkey.

За да инсталирате този скрипт, трябва да имате инсталиран скриптов мениджър.

(Вече имам скриптов мениджър, искам да го инсталирам!)

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

(Вече имам инсталиран мениджър на стиловете, искам да го инсталирам!)

// ==UserScript==
// @name MetadataParser
// @namespace -
// @version 1.0.0
// @description A solution to parse user-script metadata blocks.
// @author NotYou
// @grant none
// @require https://unpkg.com/[email protected]/lib/index.umd.js
// @require https://update.greasyfork.org/scripts/565798/1752557/Zod%203x%20Error%20Formatter.js
// ==/UserScript==

/* global ZodErrorFormatter */

(function(z) {
    'use strict';

    // Source of the meta keys formatting:

    // https://greasyfork.org/en/help/meta-keys
    // https://wiki.greasespot.net/Metadata_Block
    // https://www.tampermonkey.net/documentation.php
    // https://violentmonkey.github.io/api/metadata-block/
    // https://docs.scriptcat.org/docs/dev/meta/

    class Metadata {
        get MetadataItemSchema() {
            return z.object({
                key: z.string(),
                value: z.string().nullable(),
                locale: z.string().refine(val => /^[a-zA-Z]{2,2}(\-[a-zA-Z]{2,2})?$/.test(val), {
                    message:
                    'Locale must be either a null or ISO 639-1 string (for example: "en", "es"),' +
                    'may also include region code in this format: xx-YY (for example: "en-US", "es-ES")'
                }).nullable()
            })
        }

        constructor(metadata) {
            try {
                this.metadata = this.MetadataItemSchema.array().parse(metadata)
            } catch(err) {
                if (err instanceof z.ZodError) {
                    throw new Error(ZodErrorFormatter.format(err))
                }

                throw new Error(err)
            }
        }

        getRawData() {
            return this.metadata
        }

        indexOf(key, locale = null) {
            if (typeof locale === 'string') {
                const fullKey = key + ':' + locale

                return this.metadata.findIndex(item => typeof item.locale === 'string' && (item.key + ':' + item.locale) === fullKey)
            }

            return this.metadata.findIndex(item => item.key === key)
        }

        get(key, locale = null) {
            return this.metadata[this.indexOf(key, locale)]
        }

        getAll(key, locale = null) {
            if (typeof locale === 'string') {
                const fullKey = key + ':' + locale

                return this.metadata.filter(item => typeof item.locale === 'string' && (item.key + ':' + item.locale) === fullKey)
            }

            return this.metadata.filter(item => item.key === key)
        }

        has(key, locale) {
            return this.get(key, locale) !== undefined
        }

        add(item) {
            try {
                this.MetadataItemSchema.parse(item)

                this.metadata.push(item)
            } catch(err) {
                if (err instanceof z.ZodError) {
                    throw new Error(ZodErrorFormatter.format(err))
                }

                throw new Error(err)
            }
        }

        set(key, value, locale = null) {
            const item = this.get(key, locale)

            if (item) {
                item.value = value
            } else {
                this.add({
                    key,
                    value,
                    locale
                })
            }
        }

        forEach(callback) {
            this.metadata.forEach(callback)
        }

        remove(key, locale = null) {
            const index = this.indexOf(key, locale)

            if (index === -1) return null

            return this.metadata.splice(index, 1)
        }

        removeAll(key, locale = null) {
            const removed = []
            const final = []

            if (typeof locale === 'string') {
                for (const item of this.metadata) {
                    if (
                        typeof item.locale === 'string' &&
                        item.key + ':' + item.locale === key + ':' + locale
                    ) {
                        removed.push(item)
                    } else {
                        final.push(item)
                    }
                }

                this.metadata = final
                return removed
            }

            for (const item of this.metadata) {
                if (item.key === key) {
                    removed.push(item)
                } else {
                    final.push(item)
                }
            }

            this.metadata = final
            return removed
        }

        [Symbol.toPrimitive](hint) {
            if (typeof hint === 'number') {
                return NaN
            }

            return this.stringify()
        }

        stringify(pretty = false) {
            let longestKeyLength = 0
            if (pretty) {
                longestKeyLength = this.getRawData().reduce((acc, curr) => {
                    if (typeof curr.locale === 'string' && curr.key.length + curr.locale.length > acc) {
                        return curr.key.length + curr.locale.length + 1
                    } else if (curr.key.length > acc) {
                        return curr.key.length
                    }

                    return acc
                }, 0)
            }

            return '// ==UserScript==\n' + this.metadata.reduce((acc, curr) => {
                let leftPart = ''
                let rightPart = ''

                if (curr.locale) {
                    leftPart = `// @${curr.key}:${curr.locale}`
                } else {
                    leftPart += `// @${curr.key}`
                }

                if (curr.value) {
                    const spacesCount = longestKeyLength - curr.key.length - (typeof curr.locale === 'string' ? curr.locale.length + 1 : 0)

                    rightPart = ' '.repeat(spacesCount <= 0 ? 0 : spacesCount) + ' ' + curr.value
                }

                const metadataItem = leftPart + rightPart + '\n'

                acc += metadataItem

                return acc
            }, '') + '// ==/UserScript=='
        }
    }

    class MetadataSanitizer {
        SanitizationError = class extends Error {
            constructor(message) {
                super(message)

                this.name = 'SanitizationError'
            }
        }

        get FormatIdSchema() {
            return z.string().refine(value => Object.keys(this._formats).indexOf(value) !== -1, {
                message: 'Format id must be one of these values: ' + Object.keys(this._formats).map(e => `"${e}"`).join(', ')
            })
        }

        get ConfigSchema() {
            return z.object({
                format: this.FormatIdSchema,
                allowNonExistentKeys: z.boolean().default(true),
                allowUnexpectedMultilingualKeys: z.boolean().default(false),
                allowDuplicatesForUniqueKeys: z.boolean().default(false),
                requireMandatoryKeys: z.boolean().default(true)
            })
        }

        constructor(config = {}) {
            try {
                this.config = this.ConfigSchema.parse(config)
            } catch(err) {
                if (err instanceof z.ZodError) {
                    throw new Error('Invalid configuration provided, ' + ZodErrorFormatter.format(err))
                }

                throw new Error(err)
            }
        }

        _formats = {
            greasyfork: {
                name: {
                    required: true,
                    multilingual: true,
                    multiple: false
                },
                description: {
                    required: true,
                    multilingual: true,
                    multiple: false
                },
                namespace: {
                    required: true,
                    multilingual: false,
                    multiple: false
                },
                version: {
                    required: true,
                    multilingual: false,
                    multiple: false
                },
                match: {
                    required: true,
                    multilingual: false,
                    multiple: true,
                    alternatives: ['include']
                },
                exclude: {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                require: {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                resource: {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                updateURL: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                installURL: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                downloadURL: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                license: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                supportURL: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                contributionURL: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                contributionAmount: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                compatible: {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                incompatible: {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                antifeature: {
                    required: false,
                    multilingual: true,
                    multiple: false
                }
            },
            greasemonkey: {
                description: {
                    required: false,
                    multilingual: true,
                    multiple: false
                },
                exclude: {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                grant: {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                homepageUrl: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                icon: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                match: {
                    required: false,
                    multilingual: false,
                    multiple: true,
                    alternatives: ['include']
                },
                name: {
                    required: true,
                    multilingual: true,
                    multiple: false
                },
                namespace: {
                    required: true,
                    multilingual: false,
                    multiple: false
                },
                noframes: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                require: {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                resource: {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                'run-at': {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                version: {
                    required: false,
                    multilingual: false,
                    multiple: false
                }
            },
            tampermonkey: {
                name: {
                    required: true,
                    multilingual: true,
                    multiple: false
                },
                namespace: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                copyright: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                version: {
                    required: true,
                    multilingual: false,
                    multiple: false
                },
                description: {
                    required: true,
                    multilingual: true,
                    multiple: false
                },
                icon: {
                    required: false,
                    multilingual: false,
                    multiple: false,
                    alternatives: ['iconURL', 'defaulticon']
                },
                icon64: {
                    required: false,
                    multilingual: false,
                    multiple: false,
                    alternatives: ['icon64URL']
                },
                grant: {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                author: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                homepage: {
                    required: false,
                    multilingual: false,
                    multiple: false,
                    alternatives: ['homepageURL', 'website', 'source']
                },
                antifeature: {
                    required: false,
                    multilingual: true,
                    multiple: true
                },
                require: {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                resource: {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                resource: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                match: {
                    required: false,
                    multilingual: false,
                    multiple: true,
                    alternatives: ['include']
                },
                exclude: {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                'run-at': {
                    required: false,
                    multilingual: false,
                    multiple: false,
                },
                'run-in': {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                sandbox: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                tag: {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                connect: {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                noframes: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                updateURL: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                downloadURL: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                supportURL: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                webRequest: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                unwrap: {
                    required: false,
                    multilingual: false,
                    multiple: false
                }
            },
            violentmonkey: {
                name: {
                    required: true,
                    multilingual: true,
                    multiple: false
                },
                namespace: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                match: {
                    required: false,
                    multilingual: false,
                    multiple: true,
                    alternatives: ['include']
                },
                'match-exclude': {
                    required: false,
                    multilingual: false,
                    multiple: true,
                    alternatives: ['exclude']
                },
                version: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                description: {
                    required: false,
                    multilingual: true,
                    multiple: false
                },
                icon: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                require: {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                resource: {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                'run-at': {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                noframes: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                grant: {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                'inject-into': {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                downloadURL: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                supportURL: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                homepageURL: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                unwrap: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                'top-level-await': {
                    required: false,
                    multilingual: false,
                    multiple: false
                }
            },
            scriptcat: {
                name: {
                    required: true,
                    multilingual: true,
                    multiple: false
                },
                namespace: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                version: {
                    required: true,
                    multilingual: false,
                    multiple: false
                },
                description: {
                    required: true,
                    multilingual: true,
                    multiple: false
                },
                author: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                'run-at': {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                'run-in': {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                'early-start': {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                'inject-into': {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                storageName: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                background: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                crontab: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                match: {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                include: {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                exclude: {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                grant: {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                connect: {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                resource: {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                require: {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                'require-css': {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                noframes: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                definition: {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                antifeature: {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                license: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                updateURL: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                downloadURL: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                supportURL: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                homepage: {
                    required: false,
                    multilingual: false,
                    multiple: false,
                    alternatives: ['homepageURL', 'website']
                },
                source: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                icon: {
                    required: false,
                    multilingual: false,
                    multiple: false,
                    alternatives: ['iconURL', 'defaulticon']
                },
                icon64: {
                    required: false,
                    multilingual: false,
                    multiple: false,
                    alternatives: ['icon64URL']
                }
            }
        }

        _getKeyData(queryKey) {
            const format = this._formats[this.config.format]

            for (const key in format) {
                const keyData = format[key]

                if (queryKey === key) {
                    return { key, keyData }
                } else if (keyData.alternatives && keyData.alternatives.indexOf(queryKey) !== -1) {
                    return { key, keyData }
                }
            }

            return null
        }

        sanitize(metadata) {
            const sanitized = []
            const format = this._formats[this.config.format]
            const acquiredKeys = {}
            const items = metadata.getRawData()

            for (const item of items) {
                const keyInfo = this._getKeyData(item.key)
                const fullKey = item.key + (item.locale ? ':' + item.locale : '')

                const keyDataNotFound = keyInfo === null

                if (keyDataNotFound && !this.config.allowNonExistentKeys) {
                    throw new this.SanitizationError(`You cannot use key "${item.key}" because it is not available in the format "${this.config.format}"`)
                } else if (keyDataNotFound) {
                    sanitized.push(item)

                    continue
                }

                const keyData = keyInfo.keyData
                const notMultilingualButHasLocale = item.locale && !keyData.multilingual

                if (notMultilingualButHasLocale && !this.config.allowUnexpectedMultilingualKeys) {
                    throw new this.SanitizationError(`You cannot use locale codes for the non-multilingual key "${item.key}" with the format "${this.config.format}"`)
                }

                if (acquiredKeys[fullKey] && !keyData.multiple && !this.config.allowDuplicatesForUniqueKeys) {
                    throw new this.SanitizationError(`You cannot use key "${item.key}" multiple times with the format "${this.config.format}"`)
                }

                acquiredKeys[keyInfo.key] = true
                acquiredKeys[fullKey] = true

                if (keyData.alternatives) {
                    for (const alternative of keyData.alternatives) {
                        acquiredKeys[alternative] = true
                    }
                }

                sanitized.push(item)
            }


            if (this.config.requireMandatoryKeys) {
                const requiredKeys = Object.keys(format).filter(key => format[key].required)
                const missingKeys = requiredKeys.filter(requiredKey => !acquiredKeys[requiredKey])

                if (missingKeys.length !== 0) {
                    throw new this.SanitizationError('Not all mandatory keys are present. These keys are missing: ' + missingKeys.map(key => `"${key}"`).join(', '))
                }
            }

            return new Metadata(sanitized)
        }
    }

    class MetadataParser {
        ParsingError = class extends Error {
            constructor(message) {
                super(message)

                this.name = 'ParsingError'
            }
        }

        get ParseOptionsSchema() {
            return z.object({
                sanitizer: z.custom(value => value instanceof MetadataSanitizer).optional(),
                strictSyntax: z.boolean().default(false)
            })
        }

        constructor(defaultOptions = {}) {
            try {
                this.defaultOptions = this.ParseOptionsSchema.parse(defaultOptions)
            } catch(err) {
                if (err instanceof z.ZodError) {
                    throw new Error('Invalid options provided, ' + ZodErrorFormatter.format(err))
                }

                throw new Error(err)
            }
        }

        parse(metadataString, options) {
            if (typeof metadataString !== 'string') {
                throw new Error('Provided metadata is not a string')
            }

            const parsed = this.ParseOptionsSchema.safeParse(options)

            if (!parsed.success) {
                if (this.defaultOptions) {
                    options = this.defaultOptions
                } else {
                    throw new Error(ZodErrorFormatter.format(parsed.error))
                }
            } else {
                options = parsed.data
            }

            metadataString = metadataString.trim()

            const metadataStart = metadataString.match(/\/\/\s+==UserScript==/)
            const metadataEnd = metadataString.match(/\/\/\s+==\/UserScript==/)

            if (metadataStart === null || metadataEnd === null) {
                throw new this.ParsingError('Invalid string, cannot parse data. Could not find user script metadata comment block')
            }

            if (metadataStart.index > metadataEnd.index) {
                throw new this.ParsingError('Invalid string, cannot parse data. Metadata start comment is farther than metadata end comment')
            }

            const metadataRaw = metadataString.slice(
                metadataStart.index + metadataStart[0].length, // cut start comment -> // ==UserScript==
                metadataEnd.index // cut end comment -> // ==/UserScript==
            ).split('\n')

            const result = []

            const reMetaItem = /\/\/\s+@(?<key>[a-zA-Z\-]+)(\:(?<locale>[a-zA-Z]{2,2}(\-[a-zA-Z]{2,2})?)?)?(\s+(?<value>.*?))?$/
            const falsyToNull = val => val ? val : null

            for (let i = 0; i < metadataRaw.length; i++) {
                const line = metadataRaw[i].trim()

                if (line === '') continue

                const match = line.match(reMetaItem)
                const isInvalidSyntax = match === null

                if (isInvalidSyntax && options.strictSyntax) {
                    throw new this.ParsingError(`Invalid syntax, cannot parse the metadata. Invalid line: "${line}"`)
                } else if (isInvalidSyntax) {
                    continue
                }

                const { key, value, locale } = match.groups

                result.push({ key, value: falsyToNull(value), locale: falsyToNull(locale) })
            }

            const metadata = new Metadata(result)

            if (options.sanitizer) {
                return options.sanitizer.sanitize(metadata)
            }

            return metadata
        }

        safeParse(metadataString, options) {
            try {
                const result = this.parse(metadataString, options)

                return { success: true, data: result }
            } catch(error) {
                return { success: false, error }
            }
        }
    }

    window.Metadata = Metadata
    window.MetadataSanitizer = MetadataSanitizer
    window.MetadataParser = MetadataParser
})(window.Zod);