UNPKG

quasar-json-api

Version:

Normalizes and validates JSON API for 3rd-party Quasar Components, Directives, Mixins and Plugins using the Quasar UI Kit

716 lines (604 loc) 22.6 kB
const glob = require('glob') const path = require('path') const { merge } = require('webpack-merge') const fs = require('fs') const root = global.rootDir const resolvePath = file => path.resolve(root, file) const distRoot = path.resolve(rootDir, './dist/api') const extendApi = require('./api.extends.json') const { logError, writeFile, kebabCase } = require('./build.utils') const ast = require('./ast') const slotRegex = /\(slots\[['`](\S+)['`]\]|\(slots\.([A-Za-z]+)|hSlot\(this, '(\S+)'|hUniqueSlot\(this, '(\S+)'|hMergeSlot\(this, '(\S+)'|hMergeSlotSafely\(this, '(\S+)'/g // if destination folder does not exist, create it now if (fs.existsSync(distRoot) === false) { fs.mkdirSync(distRoot) } function getMixedInAPI (api, mainFile) { api.mixins.forEach(mixin => { let mixinFile if (mixin.charAt(0) === '~') { try { mixinFile = mixin.slice(1) + '.json' mixinFile = require.resolve(mixinFile) } catch (e) { logError(`⚠️ build.api.js: Cannot resolve mixin file: ${mixinFile}`) process.exit(1) } } else { mixinFile = resolvePath('src/' + mixin + '.json') } if (!fs.existsSync(mixinFile)) { logError(`⚠️ build.api.js: ${ path.relative(root, mainFile) } -> no such mixin ${ mixin }`) process.exit(1) } const content = require(mixinFile) api = merge( {}, content.mixins !== void 0 ? getMixedInAPI(content, mixinFile) : content, api ) }) const { mixins, ...finalApi } = api return finalApi } const topSections = { plugin: [ 'meta', 'injection', 'quasarConfOptions', 'addedIn', 'props', 'methods' ], component: [ 'meta', 'quasarConfOptions', 'addedIn', 'props', 'slots', 'events', 'methods' ], directive: [ 'meta', 'quasarConfOptions', 'addedIn', 'value', 'arg', 'modifiers' ], util: [ 'meta', 'methods', 'constants' ] } const objectTypes = { Boolean: { props: [ 'tsInjectionPoint', 'desc', 'required', 'reactive', 'sync', 'link', 'default', 'examples', 'category', 'addedIn', 'applicable', 'internal' ], required: [ 'desc' ], isBoolean: [ 'tsInjectionPoint', 'required', 'reactive', 'sync', 'internal' ], isArray: [ 'examples' ] }, String: { props: [ 'tsInjectionPoint', 'desc', 'required', 'reactive', 'sync', 'link', 'values', 'default', 'examples', 'category', 'addedIn', 'transformAssetUrls', 'applicable', 'internal' ], required: [ 'desc', 'examples' ], isBoolean: [ 'tsInjectionPoint', 'required', 'reactive', 'sync', 'transformAssetUrls', 'internal' ], isArray: [ 'examples', 'values' ] }, Number: { props: [ 'tsInjectionPoint', 'desc', 'required', 'reactive', 'sync', 'link', 'values', 'default', 'examples', 'category', 'addedIn', 'applicable', 'internal' ], required: [ 'desc', 'examples' ], isBoolean: [ 'tsInjectionPoint', 'required', 'reactive', 'sync', 'internal' ], isArray: [ 'examples', 'values' ] }, Object: { props: [ 'tsInjectionPoint', 'tsType', 'desc', 'required', 'reactive', 'sync', 'link', 'values', 'default', 'definition', 'examples', 'category', 'addedIn', 'applicable', 'internal' ], required: [ 'desc', 'examples' ], recursive: [ 'definition' ], isBoolean: [ 'tsInjectionPoint', 'required', 'reactive', 'sync', 'internal' ], isObject: [ 'definition' ], isArray: [ 'examples', 'values' ] }, Array: { props: [ 'tsInjectionPoint', 'tsType', 'desc', 'required', 'reactive', 'sync', 'link', 'values', 'default', 'definition', 'examples', 'category', 'addedIn', 'applicable', 'internal' ], required: [ 'desc', 'examples' ], isBoolean: [ 'tsInjectionPoint', 'required', 'reactive', 'sync', 'internal' ], isObject: [ 'definition' ], isArray: [ 'examples', 'values' ] }, Date: { props: [ 'tsInjectionPoint', 'tsType', 'desc', 'required', 'reactive', 'sync', 'link', 'values', 'default', 'definition', 'examples', 'category', 'addedIn', 'applicable', 'internal' ], required: [ 'desc', 'examples' ], recursive: [ 'definition' ], isBoolean: [ 'tsInjectionPoint', 'required', 'reactive', 'sync', 'internal' ], isObject: [ 'definition' ], isArray: [ 'examples', 'values' ] }, Event: { props: [ 'tsInjectionPoint', 'tsType', 'desc', 'required', 'reactive', 'sync', 'link', 'values', 'default', 'definition', 'examples', 'category', 'addedIn', 'applicable', 'internal' ], required: [ 'desc', 'examples' ], recursive: [ 'definition' ], isBoolean: [ 'tsInjectionPoint', 'required', 'reactive', 'sync', 'internal' ], isObject: [ 'definition' ], isArray: [ 'examples', 'values' ] }, Map: { props: [ 'tsInjectionPoint', 'tsType', 'desc', 'required', 'reactive', 'sync', 'link', 'values', 'default', 'definition', 'examples', 'category', 'addedIn', 'applicable', 'internal' ], required: [ 'desc', 'examples' ], recursive: [ 'definition' ], isBoolean: [ 'tsInjectionPoint', 'required', 'reactive', 'sync', 'internal' ], isObject: [ 'definition' ], isArray: [ 'examples', 'values' ] }, Promise: { props: [ 'desc', 'required', 'reactive', 'sync', 'link', 'default', 'examples', 'category', 'addedIn', 'applicable', 'internal' ], required: [ 'desc', 'examples' ], isBoolean: [ 'tsInjectionPoint', 'required', 'reactive', 'sync', 'internal' ], isObject: [ 'definition' ], isArray: [ 'examples' ] }, Function: { props: [ 'tsInjectionPoint', 'tsType', 'desc', 'required', 'reactive', 'sync', 'link', 'default', 'params', 'returns', 'examples', 'category', 'addedIn', 'applicable', 'internal', 'values' ], required: [ 'desc', 'params', 'returns' ], isBoolean: [ 'tsInjectionPoint', 'required', 'reactive', 'sync', 'internal' ], isObject: [ 'params', 'returns' ], canBeNull: [ 'params', 'returns', 'values' ], isArray: [ 'examples' ] }, MultipleTypes: { props: [ 'tsInjectionPoint', 'tsType', 'desc', 'required', 'reactive', 'sync', 'link', 'values', 'default', 'definition', 'params', 'returns', 'examples', 'category', 'addedIn', 'applicable', 'internal' ], required: [ 'desc', 'examples' ], isBoolean: [ 'tsInjectionPoint', 'required', 'reactive', 'sync', 'internal' ], isObject: [ 'definition', 'params', 'returns' ], isArray: [ 'examples', 'values' ] }, // special type, not common Error: { props: [ 'desc', 'category', 'examples', 'addedIn', 'internal' ], required: ['desc'], isBoolean: ['internal'] }, // special type, not common Component: { props: [ 'desc', 'category', 'examples', 'addedIn', 'internal' ], required: ['desc'], isBoolean: ['internal'] }, // special type, not common Ref: { props: [ 'desc', 'category', 'examples', 'addedIn', 'internal' ], required: ['desc'], isBoolean: ['internal'] }, meta: { props: [ 'docsUrl' ], required: [] }, // special type, not common Element: { props: [ 'desc', 'category', 'examples', 'addedIn', 'internal' ], required: ['desc'], isBoolean: ['internal'] }, // special type, not common File: { props: [ 'desc', 'required', 'category', 'examples', 'addedIn', 'internal' ], required: ['desc'], isBoolean: ['internal'] }, // special type, not common FileList: { props: [ 'desc', 'required', 'category', 'examples', 'addedIn', 'internal' ], required: ['desc'], isBoolean: ['internal'] }, // special type, not common MediaElement: { props: [ 'desc', 'required', 'category', 'examples', 'addedIn' ], required: [ 'desc' ] }, // component only slots: { props: [ 'desc', 'link', 'scope', 'addedIn', 'applicable', 'internal' ], required: ['desc'], isObject: ['scope'], isBoolean: ['internal'] }, // component only events: { props: [ 'desc', 'link', 'params', 'addedIn', 'applicable', 'internal' ], required: ['desc'], isObject: ['params'], isBoolean: ['internal'] }, methods: { props: [ 'tsInjectionPoint', 'tsType', 'desc', 'examples', 'link', 'params', 'returns', 'addedIn', 'applicable', 'internal', 'values' ], required: [ 'desc' ], isBoolean: [ 'tsInjectionPoint' ], isObject: [ 'tsType', 'params', 'returns' ], isArray: [ 'values' ] }, // computed: { // props: [ 'tsInjectionPoint', 'desc', 'examples', 'link', 'returns', 'addedIn', 'applicable', 'internal' ], // required: [ 'desc' ], // isBoolean: [ 'tsInjectionPoint' ], // isObject: [ 'tsType', 'params', 'returns' ], // isArray: [ 'values' ] // }, // plugin only quasarConfOptions: { props: [ 'propName', 'definition', 'link', 'addedIn' ], required: [ 'propName', 'definition' ] } } function parseObject ({ banner, api, itemName, masterType, verifyCategory }) { let obj = api[ itemName ] if (obj.extends !== void 0 && extendApi[ masterType ] !== void 0) { if (extendApi[ masterType ][ obj.extends ] === void 0) { logError(`${ banner } extends "${ obj.extends }" which does not exists`) process.exit(1) } api[ itemName ] = merge( {}, extendApi[ masterType ][ obj.extends ], api[ itemName ] ) delete api[ itemName ].extends obj = api[ itemName ] } let type if ([ 'props', 'modifiers' ].includes(masterType)) { if (obj.type === void 0) { logError(`${ banner } missing "type" prop`) process.exit(1) } type = Array.isArray(obj.type) || obj.type === 'Any' ? 'MultipleTypes' : obj.type } else { type = masterType } type = type.startsWith('Promise') ? 'Promise' : type if (objectTypes[ type ] === void 0) { logError(`${ banner } object has unrecognized API type prop value: "${ type }"`) console.error(obj) process.exit(1) } const def = objectTypes[ type ] if (obj.internal !== true) { for (const prop in obj) { if ([ 'type', '__exemption' ].includes(prop)) { continue } if (verifyCategory && obj.category === void 0) { logError(`${ banner } missing required API prop "category" for its type (${ type })`) console.error(obj) console.log() process.exit(1) } if (!def.props.includes(prop)) { logError(`${ banner } object has unrecognized API prop "${ prop }" for its type (${ type })`) console.error(obj) console.log() process.exit(1) } def.required.forEach(prop => { if (obj.__exemption !== void 0 && obj.__exemption.includes(prop)) { return } if ( !prop.examples && (obj.definition !== void 0 || obj.values !== void 0) ) { return } if (obj[ prop ] === void 0) { logError(`${ banner } missing required API prop "${ prop }" for its type (${ type })`) console.error(obj) console.log() process.exit(1) } }) if (obj.__exemption !== void 0) { const { __exemption, ...p } = obj api[ itemName ] = p } def.isBoolean && def.isBoolean.forEach(prop => { if (obj[ prop ] && obj[ prop ] !== true && obj[ prop ] !== false) { logError(`${ banner }/"${ prop }" is not a Boolean`) console.error(obj) console.log() process.exit(1) } }) def.isObject && def.isObject.forEach(prop => { if (obj[ prop ] && Object(obj[ prop ]) !== obj[ prop ]) { logError(`${ banner }/"${ prop }" is not an Object`) console.error(obj) console.log() process.exit(1) } }) def.isArray && def.isArray.forEach(prop => { if (obj[ prop ] && !Array.isArray(obj[ prop ])) { logError(`${ banner }/"${ prop }" is not an Array`) console.error(obj) console.log() process.exit(1) } }) } } if (obj.returns) { parseObject({ banner: `${ banner }/"returns"`, api: api[ itemName ], itemName: 'returns', masterType: 'props' }) } ;[ 'params', 'definition', 'scope', 'props' ].forEach(prop => { if (!obj[ prop ]) { return } for (const item in obj[ prop ]) { parseObject({ banner: `${ banner }/"${ prop }"/"${ item }"`, api: api[ itemName ][ prop ], itemName: item, masterType: 'props' }) } }) } // https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string // https://regex101.com/r/vkijKf/1/ const SEMANTIC_REGEX = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/ function isValidVersion (version) { return !!SEMANTIC_REGEX.exec(version) } function handleAddedIn (api, banner) { if (api.addedIn === void 0 || api.addedIn.length === 0) { logError(`${ banner } "addedIn" is empty`) console.log() process.exit(1) } const addedIn = api.addedIn if (addedIn.charAt(0) !== 'v') { logError(`${ banner } "addedIn" value (${ addedIn }) must start with "v"`) console.log() process.exit(1) } if (isValidVersion(addedIn.slice(1)) !== true) { logError(`${ banner } "addedIn" value (${ addedIn }) must follow sematic versioning`) console.log() process.exit(1) } } function parseAPI (file, apiType) { let api = require(file) if (api.mixins !== void 0) { api = getMixedInAPI(api, file) } const banner = `build.api.js: ${ path.relative(root, file) } -> ` if (api.meta === void 0 || api.meta.docsUrl === void 0) { logError(`${ banner } API file does not contain meta > docsUrl`) process.exit(1) } // "props", "slots", ... for (const type in api) { if (!topSections[ apiType ].includes(type)) { logError(`${ banner } "${ type }" is not recognized for a ${ apiType }`) process.exit(1) } if (type === 'injection') { if (typeof api.injection !== 'string' || api.injection.length === 0) { logError(`${ banner } "${ type }"/"injection" invalid content`) process.exit(1) } continue } if (type === 'addedIn') { handleAddedIn(api, banner) continue } if ([ 'value', 'arg', 'quasarConfOptions', 'meta' ].includes(type)) { if (Object(api[ type ]) !== api[ type ]) { logError(`${ banner } "${ type }"/"${ type }" is not an object`) process.exit(1) } } if ([ 'meta', 'quasarConfOptions' ].includes(type)) { parseObject({ banner: `${ banner } "${ type }"`, api, itemName: type, masterType: type }) continue } if ([ 'value', 'arg' ].includes(type)) { parseObject({ banner: `${ banner } "${ type }"`, api, itemName: type, masterType: 'props' }) continue } const isComponent = banner.indexOf('component') > -1 for (const itemName in api[ type ]) { parseObject({ banner: `${ banner } "${ type }"/"${ itemName }"`, api: api[ type ], itemName, masterType: type, verifyCategory: type === 'props' && isComponent }) } } return api } function orderAPI (api, apiType) { const ordered = { type: apiType } topSections[ apiType ].forEach(section => { if (api[ section ] !== void 0) { ordered[ section ] = api[ section ] } }) return ordered } function arrayHasError (name, key, property, expected, propApi) { const apiVal = propApi[ property ] if (expected.length === 1 && expected[ 0 ] === apiVal) { return } const expectedVal = expected.filter(t => t.startsWith('__') === false) if ( !Array.isArray(apiVal) || apiVal.length !== expectedVal.length || !expectedVal.every(t => apiVal.includes(t)) ) { console.log(key, name, propApi[ key ], expectedVal) logError(`${ name }: wrong definition for prop "${ key }" on "${ property }": expected ${ expectedVal } but found ${ apiVal }`) return true } } function fillAPI (apiType, list) { return file => { const name = path.basename(file), filePath = path.join(distRoot, name) const api = orderAPI(parseAPI(file, apiType), apiType) if (apiType === 'component') { let hasError = false // QUploader has different definition if (name !== 'QUploader.json') { const filePath = file.replace('.json', fs.existsSync(file.replace('.json', '.js')) ? '.js' : '.ts') const definition = fs.readFileSync(filePath, 'utf-8') let slotMatch while ((slotMatch = slotRegex.exec(definition)) !== null) { const slotName = (slotMatch[ 2 ] || slotMatch[ 3 ] || slotMatch[ 4 ] || slotMatch[ 5 ] || slotMatch[ 6 ] || slotMatch[ 7 ]).replace(/(\${.+})/g, '[name]') if (!(api.slots || {})[ slotName ]) { logError(`${ name }: missing "slot" -> "${ slotName }" definition`) hasError = true // keep looping through to find as many as can be found before exiting } else if (api.slots[ slotName ].internal === true) { delete api.slots[ slotName ] } } ast.evaluate(definition, topSections[ apiType ], (prop, key, definition) => { if (prop === 'props') { if (!key && definition.type === 'Function') { // TODO // wrong evaluation; example: QTabs: props > 'onUpdate:modelValue' return } key = key.replace(/([a-z])([A-Z])/g, '$1-$2') .replace(/\s+/g, '-') .toLowerCase() if (/^on-/.test(key) === true) { return } } if (api[ prop ] === void 0 || api[ prop ][ key ] === void 0) { logError(`${ name }: missing "${ prop }" -> "${ key }" definition`) hasError = true // keep looping through to find as many as can be found before exiting } if (definition) { const propApi = api[ prop ][ key ] if (typeof definition === 'string' && propApi.type !== definition) { logError(`${ name }: wrong definition for prop "${ key }": expected "${ definition }" but found "${ propApi.type }"`) hasError = true // keep looping through to find as many as can be found before exiting } else if (Array.isArray(definition)) { if (arrayHasError(name, key, 'type', definition, propApi)) { hasError = true // keep looping through to find as many as can be found before exiting } } else { if (definition.type) { if (Array.isArray(definition.type)) { if (arrayHasError(name, key, 'type', definition.type, propApi)) { hasError = true } } else if (propApi.type !== definition.type) { logError(`${ name }: wrong definition for prop "${ key }" on "type": expected "${ definition.type }" but found "${ propApi.type }"`) hasError = true // keep looping through to find as many as can be found before exiting } } if (key !== 'value' && definition.required && Boolean(definition.required) !== propApi.required) { logError(`${ name }: wrong definition for prop "${ key }" on "required": expected "${ definition.required }" but found "${ propApi.required }"`) hasError = true // keep looping through to find as many as can be found before exiting } if (definition.validator && Array.isArray(definition.validator)) { if (arrayHasError(name, key, 'values', definition.validator, propApi)) { hasError = true // keep looping through to find as many as can be found before exiting } } } } }) } if (api.props !== void 0) { for (const key in api.props) { if (api.props[ key ].internal === true) { delete api.props[ key ] } } } if (hasError === true) { logError('Errors were found...exiting') process.exit(1) } } // copy API file to dest writeFile(filePath, JSON.stringify(api, null, 2)) const shortName = name.substring(0, name.length - 5) list.push(shortName) return { name: shortName, api } } } function writeTransformAssetUrls (components) { const transformAssetUrls = { base: null, includeAbsolute: false, tags: { video: [ 'src', 'poster' ], source: ['src'], img: ['src'], image: [ 'xlink:href', 'href' ], use: [ 'xlink:href', 'href' ] } } components.forEach(({ name, api }) => { if (api.props !== void 0) { let props = Object.keys(api.props) .filter(name => api.props[ name ].transformAssetUrls === true) if (props.length > 0) { props = props.length > 1 ? props : props[ 0 ] transformAssetUrls.tags[ name ] = props transformAssetUrls.tags[ kebabCase(name) ] = props } } }) writeFile( path.join(root, 'dist/transforms/loader-asset-urls.json'), JSON.stringify(transformAssetUrls, null, 2) ) } function writeApiIndex (list) { writeFile( path.join(root, 'dist/transforms/api-list.json'), JSON.stringify(list, null, 2) ) } module.exports.generate = function () { return new Promise((resolve) => { const list = [] const plugins = glob.sync(resolvePath('src/plugins/*.json')) .filter(file => !path.basename(file).startsWith('__')) .map(fillAPI('plugin', list)) const directives = glob.sync(resolvePath('src/directives/*.json')) .filter(file => !path.basename(file).startsWith('__')) .map(fillAPI('directive', list)) const components = glob.sync(resolvePath('src/components/**/*.json')) .filter(file => !path.basename(file).startsWith('__')) .map(fillAPI('component', list)) const utils = glob.sync(resolvePath('src/utils/**/*.json')) .filter(file => !path.basename(file).startsWith('__')) .map(fillAPI('util', list)) // writeTransformAssetUrls(components) resolve({ components, directives, plugins, utils }) }).catch(err => { logError('build.api.js: something went wrong...') console.log() console.error(err) console.log() process.exit(1) }) }