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