MetadataParser

A solution to parse user-script metadata blocks.

Acest script nu ar trebui instalat direct. Aceasta este o bibliotecă pentru alte scripturi care este inclusă prin directiva meta a // @require https://update.greasyfork.org/scripts/566592/1756806/MetadataParser.js

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

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