MetadataParser

A solution to parse user-script metadata blocks.

Este script no debería instalarse directamente. Es una biblioteca que utilizan otros scripts mediante la meta-directiva de inclusión // @require https://update.greasyfork.org/scripts/566592/1756806/MetadataParser.js

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

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

Tendrás que instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Tendrás que instalar una extensión como Tampermonkey antes de poder instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

// ==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);