A solution to parse user-script metadata blocks.
Dieses Skript sollte nicht direkt installiert werden. Es handelt sich hier um eine Bibliothek für andere Skripte, welche über folgenden Befehl in den Metadaten eines Skriptes eingebunden wird // @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);