From 69823f07c2d8fcc486275e4757fb5a79f50c9bed Mon Sep 17 00:00:00 2001 From: Razvan Stoenescu Date: Fri, 3 May 2024 21:55:50 +0300 Subject: [PATCH] feat(ui): significantly harden JSON validation algorithm --- pnpm-lock.yaml | 21 - ui/build/ast.js | 85 -- ui/build/build.api.js | 1204 ++++++++++++----- ui/build/build.utils.js | 32 +- ui/package.json | 1 - .../components/breadcrumbs/QBreadcrumbs.json | 3 +- ui/src/components/btn-toggle/QBtnToggle.json | 4 +- ui/src/components/btn/QBtn.json | 7 +- ui/src/components/card/QCardActions.json | 1 + ui/src/components/carousel/QCarousel.json | 7 +- ui/src/components/color/QColor.json | 1 + ui/src/components/date/QDate.json | 1 + ui/src/components/dialog/QDialog.json | 12 +- ui/src/components/editor/QEditor.json | 8 +- .../expansion-item/QExpansionItem.json | 4 + ui/src/components/footer/QFooter.json | 4 +- ui/src/components/header/QHeader.json | 4 +- ui/src/components/input/QInput.json | 31 +- ui/src/components/item/QItem.json | 4 +- ui/src/components/menu/QMenu.json | 4 +- .../page-scroller/QPageScroller.json | 4 + ui/src/components/pagination/QPagination.json | 5 +- ui/src/components/select/QSelect.json | 13 +- ui/src/components/splitter/QSplitter.json | 1 + ui/src/components/stepper/QStep.json | 6 + ui/src/components/table/QTable.json | 12 +- ui/src/components/table/QTh.json | 4 + ui/src/components/time/QTime.json | 2 + .../private.use-panel/use-panel.json | 2 + ui/testing/specs/specs.utils.js | 16 +- 30 files changed, 988 insertions(+), 515 deletions(-) delete mode 100644 ui/build/ast.js diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fd43d804328..0fe52e24484 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -664,9 +664,6 @@ importers: prettier: specifier: ^3.2.5 version: 3.2.5 - recast: - specifier: ^0.23.6 - version: 0.23.6 sass-embedded: specifier: ^1.75.0 version: 1.75.0 @@ -5359,13 +5356,6 @@ packages: resolution: {integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==} dev: true - /ast-types@0.16.1: - resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==} - engines: {node: '>=4'} - dependencies: - tslib: 2.6.2 - dev: true - /astral-regex@2.0.0: resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} engines: {node: '>=8'} @@ -10936,17 +10926,6 @@ packages: dependencies: picomatch: 2.3.1 - /recast@0.23.6: - resolution: {integrity: sha512-9FHoNjX1yjuesMwuthAmPKabxYQdOgihFYmT5ebXfYGBcnqXZf3WOVz+5foEZ8Y83P4ZY6yQD5GMmtV+pgCCAQ==} - engines: {node: '>= 4'} - dependencies: - ast-types: 0.16.1 - esprima: 4.0.1 - source-map: 0.6.1 - tiny-invariant: 1.3.3 - tslib: 2.6.2 - dev: true - /regenerate-unicode-properties@10.1.1: resolution: {integrity: sha512-X007RyZLsCJVVrjgEFVpLUTZwyOZk3oiL75ZcuYjlIWd6rNJtOjkBwQc5AsRrpbKVkxN6sklw/k/9m2jJYOf8Q==} engines: {node: '>=4'} diff --git a/ui/build/ast.js b/ui/build/ast.js deleted file mode 100644 index 64ad9d1918b..00000000000 --- a/ui/build/ast.js +++ /dev/null @@ -1,85 +0,0 @@ -import recast from 'recast' -import parser from 'recast/parsers/babel.js' - -// Analyze component JS file -export function astEvaluate (source, lookup, callback) { - const ast = recast.parse(source, { parser }) - for (const node of ast.program.body) { - if (node.type === 'ExportDefaultDeclaration') { - const properties - = node.declaration.properties // When exporting a plain object (`export default { ... }`) - || node.declaration.arguments[ 0 ].properties // When exporting a wrapped object (`export default defineComponent({ ... })`) - for (const property of properties) { - const propName = property.key.name - if (lookup.includes(propName)) { - const innerProps = property.value.properties - if (innerProps !== void 0) { // TODO vue3 - fix AST - for (const innerProp of innerProps) { - let definition = null - if (propName === 'props' && innerProp.value) { - definition = getPropDefinition(innerProp) - } - innerProp.key !== void 0 && callback(propName, innerProp.key.name, definition) - } - } - } - } - } - } -} - -function getPropDefinition (innerProp) { - let definition = {} - if (innerProp.value.type === 'Identifier') { - definition.type = innerProp.value.name - } - else if (innerProp.value.type === 'ArrayExpression') { - definition.type = innerProp.value.elements.map(e => e.name) - } - else if (innerProp.value.type !== 'ConditionalExpression') { - const jsonContent = innerProp.value.properties.map(p => { - let value - if (p.value) { - if (p.value.name || p.value.value) { - value = `"${ p.value.name || p.value.value }"` - } - else if (p.value.type === 'ArrowFunctionExpression') { - if (p.value.body.type === 'ArrayExpression') { - value = `[${ p.value.body.elements.map(e => e.extra.raw || e.value).join(', ') }]` - } - else if (!p.value.body.callee || !p.value.body.callee.object || !p.value.body.callee.object.elements) { - return '' - } - else { - value = `[${ p.value.body.callee.object.elements.map(e => e.extra.raw || e.value).join(', ') }]` - } - } - else if (p.value.type === 'FunctionExpression') { - value = `[${ p.value.body.body.argument.callee.object.elements.map(e => e.extra.raw || e.value).join(', ') }]` - } - } - else { - return '' - } - if (value === void 0) { - return '' - } - return `"${ p.key.name }": ${ value }` - }).filter(c => !!c).map(c => c.replace(/'/g, '"')).join(', ') - definition = JSON.parse(`{${ jsonContent }}`) - } - - if (Array.isArray(innerProp.value.properties) === true) { - const defaultVal = innerProp.value.properties.find(p => p.key.name === 'default') - if (defaultVal !== void 0 && defaultVal.value !== void 0 && defaultVal.value.type === 'NullLiteral') { - if (Array.isArray(definition.type) === true && definition.type.indexOf('null') === -1) { - definition.type.push('null') - } - else if (typeof definition.type === 'string') { - definition.type = [ definition.type, 'null' ] - } - } - } - - return definition -} diff --git a/ui/build/build.api.js b/ui/build/build.api.js index 1a1bffbcfe8..8386395720c 100644 --- a/ui/build/build.api.js +++ b/ui/build/build.api.js @@ -10,23 +10,28 @@ import { logError, readJsonFile, writeFile, - kebabCase + kebabCase, + pascalCase, + capitalize, + plural } from './build.utils.js' -import { astEvaluate } from './ast.js' - const dest = resolveToRoot('dist/api') const extendApi = readJsonFile( resolveToRoot('src/api.extends.json') ) -const slotRegex = /\(slots\[['`](\S+)['`]\]|\(slots\.([A-Za-z]+)|hSlot\(this, '(\S+)'|hUniqueSlot\(this, '(\S+)'|hMergeSlot\(this, '(\S+)'|hMergeSlotSafely\(this, '(\S+)'/g +const passthroughValues = [ true, false, 'child' ] + +const slotRegex = /slots\[\s*['"](\S+)['"]\s*\]|slots\.([A-Za-z]+)/g +const emitRegex = /emit\(\s*['"](\S+)['"]/g + const apiIgnoreValueRegex = /^# / const apiValuePromiseRegex = /\.then\(/ const apiValueRegex = { Number: /^-?\d/, - String: /^'[^']+'$/, + String: /^'[^']*'$/, Array: /^\[.*\]$/, Object: /^{.*}$/, Boolean: /^(true|false)$/, @@ -44,39 +49,119 @@ const apiValueRegex = { undefined: /^void 0$/ } -function getMixedInAPI (api, mainFile) { - api.mixins.forEach(mixin => { - const mixinFile = resolveToRoot('src/' + mixin + '.json') +const topSections = { + // also update /ui/testing/generators/generator.plugin.js on the rootProps + plugin: { + rootProps: [], // computed after this declaration + rootValidations: { + meta: val => (Object(val) === val || "'meta' must be an Object"), + addedIn: parseAddedIn, + internal: val => (typeof val === 'boolean' || '"internal" must be a Boolean'), + injection: val => (typeof val === 'string' || '"injection must be a string"'), + quasarConfOptions: val => (Object(val) === val || "'quasarConfOptions' must be an Object"), + props: val => parseObjectWithPascalCaseProps(val, 'props'), + methods: val => parseObjectWithPascalCaseProps(val, 'methods') + } + }, - if (!fse.existsSync(mixinFile)) { - logError(`build.api.js: ${ relativeToRoot(mainFile) } -> no such mixin ${ mixin }`) - process.exit(1) + // also update: /ui/testing/generators/generator.component.js on the rootProps + component: { + rootProps: [], // computed after this declaration + rootValidations: { + meta: val => (Object(val) === val || "'meta' must be an Object"), + addedIn: parseAddedIn, + quasarConfOptions: val => parseObjectWithPascalCaseProps(val, 'quasarConfOptions'), + props: val => parseObjectWithKebabCaseProps(val, 'props'), + slots: val => (Object(val) === val || "'slots' must be an Object"), // TODO Qv3: kebabCase + events: val => parseObjectWithKebabCaseProps(val, 'events'), + methods: val => parseObjectWithPascalCaseProps(val, 'methods'), + computedProps: val => parseObjectWithPascalCaseProps(val, 'computedProps') } + }, - const content = readJsonFile(mixinFile) + // also update /ui/testing/generators/generator.directive.js on the rootProps + directive: { + rootProps: [], // computed after this declaration + rootValidations: { + meta: val => (Object(val) === val || "'meta' must be an Object"), + addedIn: parseAddedIn, + quasarConfOptions: val => parseObjectWithPascalCaseProps(val, 'quasarConfOptions'), + value: val => (Object(val) === val || "'value' must be an Object"), + arg: val => (Object(val) === val || "'arg' must be an Object"), + modifiers: val => parseObjectWithPascalCaseProps(val, 'modifiers') + } + } +} +Object.keys(topSections).forEach(section => { + topSections[ section ].rootProps = Object.keys(topSections[ section ].rootValidations) +}) - api = merge( - {}, - content.mixins !== void 0 - ? getMixedInAPI(content, mixinFile) - : content, - api - ) - }) +// https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string +// https://regex101.com/r/vkijKf/1/ +const SEMANTIC_REGEX = /^v(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-]+)*))?$/ - const { mixins, ...finalApi } = api - return finalApi +function parseAddedIn (val) { + if (val === void 0 || val === null) { + return '"addedIn" has erroneous content' + } + + if (typeof val !== 'string') { + return '"addedIn" is not a string' + } + + if (val.length === 0) { + return '"addedIn" is empty' + } + + if (val.charAt(0) !== 'v') { + return `"addedIn" value (${ val }) must start with "v"` + } + + if (SEMANTIC_REGEX.test(val) !== true) { + return `"addedIn" value (${ val }) must follow semantic versioning` + } + + if (val.endsWith('.0') === true) { + return `"addedIn" value (${ val }) must not end with '.0' (remove it)` + } + + return true } -const topSections = { - // also update /ui/testing/generators/generator.plugin.js - plugin: [ 'meta', 'injection', 'quasarConfOptions', 'addedIn', 'props', 'methods', 'internal' ], +function parseObjectWithPascalCaseProps (obj, objName) { + if (Object(obj) !== obj) { + return `"${ objName }" must be an Object` + } + + const invalidProps = [] + for (const key in obj) { + if (key !== pascalCase(key)) { + invalidProps.push(key) + } + } + + return ( + invalidProps.length === 0 + || `"${ objName }" has non pascalCase key${ plural(invalidProps.length) }: ${ invalidProps.join(', ') }` + ) +} + +function parseObjectWithKebabCaseProps (obj, objName) { + if (Object(obj) !== obj) { + return `"${ objName }" must be an Object` + } - // also update: /ui/testing/generators/generator.component.js - component: [ 'meta', 'quasarConfOptions', 'addedIn', 'props', 'slots', 'events', 'methods', 'computedProps' ], + const invalidProps = [] + for (const key in obj) { + if (key !== kebabCase(key)) { + invalidProps.push(key) + } + } - // also update /ui/testing/generators/generator.directive.js - directive: [ 'meta', 'quasarConfOptions', 'addedIn', 'value', 'arg', 'modifiers' ] + return ( + invalidProps.length === 0 + || `"${ objName }" has non kebab-case key${ plural(invalidProps.length) }: ${ invalidProps.join(', ') }` + ) } const nativeTypes = [ 'Component', 'Error', 'Element', 'File', 'FileList', 'Event', 'SubmitEvent' ] @@ -85,7 +170,7 @@ const objectTypes = { Boolean: { props: [ 'tsInjectionPoint', 'tsType', 'desc', 'required', 'reactive', 'sync', 'syncable', 'link', 'default', 'examples', 'category', 'addedIn', 'passthrough', 'internal' ], required: [ 'desc' ], - isBoolean: [ 'tsInjectionPoint', 'required', 'reactive', 'sync', 'syncable', 'passthrough', 'internal' ], + isBoolean: [ 'tsInjectionPoint', 'required', 'reactive', 'sync', 'syncable', 'internal' ], isArray: [ 'examples' ], isString: [ 'tsType', 'desc', 'category', 'addedIn' ] }, @@ -93,7 +178,7 @@ const objectTypes = { String: { props: [ 'tsInjectionPoint', 'tsType', 'desc', 'required', 'reactive', 'sync', 'syncable', 'link', 'values', 'default', 'examples', 'category', 'addedIn', 'transformAssetUrls', 'passthrough', 'internal' ], required: [ 'desc' ], - isBoolean: [ 'tsInjectionPoint', 'required', 'reactive', 'sync', 'syncable', 'transformAssetUrls', 'internal', 'passthrough' ], + isBoolean: [ 'tsInjectionPoint', 'required', 'reactive', 'sync', 'syncable', 'transformAssetUrls', 'internal' ], isArray: [ 'examples', 'values' ], isString: [ 'tsType', 'desc', 'category', 'addedIn' ] }, @@ -101,7 +186,7 @@ const objectTypes = { Number: { props: [ 'tsInjectionPoint', 'tsType', 'desc', 'required', 'reactive', 'sync', 'syncable', 'link', 'values', 'default', 'examples', 'category', 'addedIn', 'passthrough', 'internal' ], required: [ 'desc' ], - isBoolean: [ 'tsInjectionPoint', 'required', 'reactive', 'sync', 'syncable', 'passthrough', 'internal' ], + isBoolean: [ 'tsInjectionPoint', 'required', 'reactive', 'sync', 'syncable', 'internal' ], isArray: [ 'examples', 'values' ], isString: [ 'tsType', 'desc', 'category', 'addedIn' ] }, @@ -110,7 +195,7 @@ const objectTypes = { props: [ 'tsInjectionPoint', 'tsType', 'autoDefineTsType', 'desc', 'required', 'reactive', 'sync', 'syncable', 'link', 'values', 'default', 'definition', 'examples', 'category', 'addedIn', 'passthrough', 'internal' ], required: [ 'desc' ], recursive: [ 'definition' ], - isBoolean: [ 'tsInjectionPoint', 'required', 'reactive', 'sync', 'syncable', 'passthrough', 'internal' ], + isBoolean: [ 'tsInjectionPoint', 'required', 'reactive', 'sync', 'syncable', 'internal' ], isObject: [ 'definition' ], isArray: [ 'examples', 'values' ], isString: [ 'tsType', 'desc', 'category', 'addedIn' ] @@ -119,7 +204,7 @@ const objectTypes = { Array: { props: [ 'tsInjectionPoint', 'tsType', 'autoDefineTsType', 'desc', 'required', 'reactive', 'sync', 'syncable', 'link', 'values', 'default', 'definition', 'examples', 'category', 'addedIn', 'passthrough', 'internal' ], required: [ 'desc' ], - isBoolean: [ 'tsInjectionPoint', 'required', 'reactive', 'sync', 'syncable', 'passthrough', 'internal' ], + isBoolean: [ 'tsInjectionPoint', 'required', 'reactive', 'sync', 'syncable', 'internal' ], isObject: [ 'definition' ], isArray: [ 'examples', 'values' ], isString: [ 'tsType', 'desc', 'category', 'addedIn' ] @@ -128,7 +213,7 @@ const objectTypes = { Promise: { props: [ 'tsInjectionPoint', 'tsType', 'desc', 'required', 'reactive', 'sync', 'syncable', 'link', 'default', 'examples', 'category', 'addedIn', 'passthrough', 'internal' ], required: [ 'desc' ], - isBoolean: [ 'tsInjectionPoint', 'required', 'reactive', 'sync', 'syncable', 'passthrough', 'internal' ], + isBoolean: [ 'tsInjectionPoint', 'required', 'reactive', 'sync', 'syncable', 'internal' ], isObject: [ 'definition' ], isArray: [ 'examples' ], isString: [ 'tsType', 'desc', 'category', 'addedIn' ] @@ -137,7 +222,7 @@ const objectTypes = { Function: { props: [ 'tsInjectionPoint', 'tsType', 'autoDefineTsType', 'desc', 'required', 'reactive', 'sync', 'syncable', 'link', 'default', 'params', 'returns', 'examples', 'category', 'addedIn', 'passthrough', 'internal' ], required: [ 'desc', 'params', 'returns' ], - isBoolean: [ 'tsInjectionPoint', 'required', 'reactive', 'sync', 'syncable', 'passthrough', 'internal' ], + isBoolean: [ 'tsInjectionPoint', 'required', 'reactive', 'sync', 'syncable', 'internal' ], isObject: [ 'params', 'returns' ], isArray: [ 'examples' ], isString: [ 'tsType', 'desc', 'category', 'addedIn' ] @@ -146,7 +231,7 @@ const objectTypes = { MultipleTypes: { props: [ 'tsInjectionPoint', 'tsType', 'autoDefineTsType', 'desc', 'required', 'reactive', 'sync', 'syncable', 'link', 'values', 'default', 'definition', 'params', 'returns', 'examples', 'category', 'addedIn', 'passthrough', 'internal' ], required: [ 'desc' ], - isBoolean: [ 'tsInjectionPoint', 'required', 'reactive', 'sync', 'syncable', 'passthrough', 'internal' ], + isBoolean: [ 'tsInjectionPoint', 'required', 'reactive', 'sync', 'syncable', 'internal' ], isObject: [ 'definition', 'params', 'returns' ], isArray: [ 'examples', 'values' ], isString: [ 'tsType', 'desc', 'category', 'addedIn' ] @@ -154,7 +239,7 @@ const objectTypes = { meta: { props: [ 'docsUrl' ], - required: [] + required: [ 'docsUrl' ] }, // component only @@ -171,7 +256,7 @@ const objectTypes = { props: [ 'tsType', 'desc', 'link', 'params', 'addedIn', 'passthrough', 'internal' ], required: [ 'desc' ], isObject: [ 'params' ], - isBoolean: [ 'passthrough', 'internal' ], + isBoolean: [ 'internal' ], isString: [ 'tsType', 'desc', 'addedIn' ] }, @@ -203,9 +288,9 @@ const objectTypes = { nativeTypes.forEach(name => { objectTypes[ name ] = { - props: [ 'tsType', 'desc', 'required', 'category', 'examples', 'addedIn', 'passthrough', 'internal' ], + props: [ 'tsType', 'desc', 'required', 'category', 'examples', 'addedIn', 'internal' ], required: [ 'desc' ], - isBoolean: [ 'passthrough', 'internal' ], + isBoolean: [ 'internal', 'required' ], isString: [ 'tsType', 'desc', 'category', 'addedIn' ] } }) @@ -245,17 +330,188 @@ function isSerializable (value) { return types.every(type => serializableTypes.includes(type)) } +function getApiWithMixins (api, mainFile) { + api.mixins.forEach(mixin => { + const mixinFile = resolveToRoot('src/' + mixin + '.json') + + if (!fse.existsSync(mixinFile)) { + logError(`build.api.js: ${ relativeToRoot(mainFile) } -> no such mixin ${ mixin }`) + process.exit(1) + } + + const content = readJsonFile(mixinFile) + + api = merge( + {}, + content.mixins !== void 0 + ? getApiWithMixins(content, mixinFile) + : content, + api + ) + }) + + const { mixins, ...finalApi } = api + return finalApi +} + +function deCapitalize (str) { + return str.charAt(0).toLowerCase() + str.slice(1) +} + +const arrayRE = /(\[.*\])/ +const objectRE = /(\{.*\})/ +const functionRE = /^(\s*\(\s*\)\s*=>\s*).+/ +function encodeDefaultValue (val, isFunction) { + if (typeof val === 'string') { + return `'${ val }'` + } + + if (typeof val === 'function') { + const fn = val.toString() + + if (isFunction === true) return fn + + const arrayMatch = fn.match(arrayRE) + if (arrayMatch !== null) { + return arrayMatch[ 1 ] + } + + const objMatch = fn.match(objectRE) + if (objMatch !== null) { + return objMatch[ 1 ] + } + + const arrowMatch = fn.match(functionRE) + if (arrowMatch !== null) { + return fn.substring(arrowMatch[ 1 ].length) + } + } + + return '' + val +} + +const runtimePropTypeToAny = [ 'File', 'FileList', 'Element' ] +const runtimePropTypeExceptions = [ 'null', 'undefined' ] +function extractRuntimeDefinablePropTypes (apiTypes) { + if (apiTypes.includes('Any') === true) { + return [ 'Any' ] + } + + return apiTypes.some(key => runtimePropTypeToAny.includes(key) === true) + ? [ 'Any' ] + : apiTypes.filter(key => runtimePropTypeExceptions.includes(key) === false).sort() +} + +function parseRuntimeType (runtimeConstructor) { + // String.toString() -> "function String() { [native code] }" + const str = runtimeConstructor.toString() + const match = str.match(/function (\w+)\(/) + return match?.[ 1 ] +} + +const typeofRE = /typeof\s+[a-zA-Z0-9$_]+\s+===\s+'([a-zA-Z]+)'/ +function extractRuntimePropAttrs (runtimeProp) { + if (Array.isArray(runtimeProp)) { + return { + runtimeTypes: runtimeProp.map(parseRuntimeType).sort(), + isRuntimeRequired: false, + hasRuntimeDefault: false + } + } + + const runtimeType = parseRuntimeType(runtimeProp) + if (runtimeType !== void 0) { + return { + runtimeTypes: [ runtimeType ], + isRuntimeRequired: false, + hasRuntimeDefault: false + } + } + + // else... it's a definition in Object form { ... } + + let runtimeTypes + + if (Array.isArray(runtimeProp.type) === true) { + runtimeTypes = runtimeProp.type.map(parseRuntimeType) + + if (runtimeTypes.includes('Any') === true) { + runtimeTypes = [ 'Any' ] + } + else { + runtimeTypes.sort() + } + } + else if (runtimeProp.type !== void 0) { + runtimeTypes = [ parseRuntimeType(runtimeProp.type) ] + } + else if (runtimeProp.validator !== void 0) { + /** + * Example (we want Number AND null to be valid): + * + * modelValue: { + * default: null, + * validator: v => typeof v === 'number' || v === null + * } + */ + + runtimeTypes = [] + const fn = runtimeProp.validator.toString() + + const match = fn.match(typeofRE) + if (match !== null) { + runtimeTypes.push( + capitalize(match[ 1 ]) + ) + } + + if (fn.indexOf('Array.isArray') !== -1) { + runtimeTypes.push('Array') + } + + if (fn.indexOf('Object') !== -1) { + runtimeTypes.push('Object') + } + + if (runtimeTypes.length === 0) { + runtimeTypes = [] + } + else { + runtimeTypes.sort() + } + } + else { + runtimeTypes = [ 'Any' ] + } + + return { + runtimeTypes, + isRuntimeRequired: runtimeProp.required === true, + hasRuntimeDefault: runtimeProp.hasOwnProperty('default'), + runtimeDefaultValue: runtimeProp.default + } +} + function parseObject ({ banner, api, itemName, masterType, verifyCategory, verifySerializable }) { let obj = api[ itemName ] - if (obj.addedIn !== void 0) { - handleAddedIn(obj.addedIn, banner) + const printErrorAndExit = msg => { + logError(`${ banner } ${ msg }`) + console.error(obj) + console.log() + process.exit(1) + } + + if (obj.hasOwnProperty('addedIn') === true) { + const result = parseAddedIn(obj.addedIn) + if (result !== true) { + printErrorAndExit(result) + } } 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) + printErrorAndExit(`extends "${ obj.extends }" which does not exists`) } api[ itemName ] = merge( @@ -268,12 +524,30 @@ function parseObject ({ banner, api, itemName, masterType, verifyCategory, verif obj = api[ itemName ] } + // there are cases where you extend something but you + // need to remove some props from the extended object + if (obj.__delete !== void 0) { + if (Array.isArray(obj.__delete) === false) { + printErrorAndExit('"__delete" prop must be an Array') + } + + if (obj.__delete.some(prop => typeof prop !== 'string')) { + printErrorAndExit('"__delete" prop must be an Array of Strings') + } + + obj.__delete.forEach(prop => { + delete obj[ prop ] + }) + + // now delete the __delete prop itself (we don't need it in the final API) + delete obj.__delete + } + let type if ([ 'props', 'modifiers' ].includes(masterType)) { if (obj.type === void 0) { - logError(`${ banner } missing "type" prop`) - process.exit(1) + printErrorAndExit('missing "type" prop') } type = Array.isArray(obj.type) || obj.type === 'Any' @@ -285,15 +559,12 @@ function parseObject ({ banner, api, itemName, masterType, verifyCategory, verif } type = type.startsWith('Promise') ? 'Promise' : type + const def = objectTypes[ type ] - if (objectTypes[ type ] === void 0) { - logError(`${ banner } object has unrecognized API type prop value: "${ type }"`) - console.error(obj) - process.exit(1) + if (def === void 0) { + printErrorAndExit(`object has unrecognized API type prop value: "${ type }"`) } - const def = objectTypes[ type ] - if (obj.internal !== true) { const regexList = Array.isArray(obj.type) ? (obj.type.includes('Any') ? [] : obj.type.map(t => apiValueRegex[ t ]).filter(v => v)) @@ -305,16 +576,25 @@ function parseObject ({ banner, api, itemName, masterType, verifyCategory, verif continue } + if (prop === '__runtimeDefault') { + if (obj.__runtimeDefault !== true) { + printErrorAndExit( + 'props > "__runtimeDefault" should only be set to true; Solutions:' + + '\n 1. delete it as it is indeed an error' + + '\n 2. it is being inherited, so add "delete": [ "__runtimeDefault" ]' + ) + } + + continue + } + // 'configFileType' is always valid in any level of 'quasarConfOptions' and nothing else if (prop === 'configFileType' && banner.includes('"quasarConfOptions"')) { continue } 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) + printErrorAndExit(`object has unrecognized API prop "${ prop }" for its type (${ type })`) } } @@ -322,16 +602,46 @@ function parseObject ({ banner, api, itemName, masterType, verifyCategory, verif if (obj.__exemption !== void 0 && obj.__exemption.includes(prop)) { return } + // 'examples' property is not required if 'definition' or 'values' properties are specified if (prop === 'examples' && (obj.definition !== void 0 || obj.values !== void 0)) { + const matchedProp = obj.definition !== void 0 + ? 'definition' + : 'values' + + printErrorAndExit(`"examples" is not needed because there is "${ matchedProp }"; remove it`) 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) + printErrorAndExit(`missing required API prop "${ prop }" for its type (${ type })`) + } + }) + + // Since we processed '__exemption', we can strip it + if (obj.__exemption !== void 0) { + const { __exemption, ...p } = obj + api[ itemName ] = p + } + + def.isBoolean && def.isBoolean.forEach(prop => { + if (obj.hasOwnProperty(prop) && obj[ prop ] !== true && obj[ prop ] !== false) { + printErrorAndExit(`"${ prop }" is not a Boolean`) + } + }) + def.isObject && def.isObject.forEach(prop => { + if (obj[ prop ] && Object(obj[ prop ]) !== obj[ prop ]) { + printErrorAndExit(`"${ prop }" is not an Object`) + } + }) + def.isArray && def.isArray.forEach(prop => { + if (obj[ prop ] && !Array.isArray(obj[ prop ])) { + printErrorAndExit(`"${ prop }" is not an Array`) + } + }) + def.isString && def.isString.forEach(prop => { + if (obj[ prop ] && typeof obj[ prop ] !== 'string') { + printErrorAndExit(`"${ prop }" is not a String`) } }) @@ -339,40 +649,17 @@ function parseObject ({ banner, api, itemName, masterType, verifyCategory, verif const list = Array.isArray(obj.type) ? obj.type : [ obj.type ] list.forEach(t => { if (typeList.includes(t) === false) { - logError(`${ banner } object has unrecognized type "${ t }"; if this is a new type, then add it to the "typeList" array in build.api.js`) - console.error(obj) - console.log() - process.exit(1) + printErrorAndExit( + `object has unrecognized type "${ t }"; if this is a new type, then ` + + 'add it to the "typeList" array in build.api.js' + ) } }) } - if (obj.default) { - if (typeof obj.default !== 'string') { - logError(`${ banner } object: stringify "default" value`) - console.error(obj) - console.log() - process.exit(1) - } - - if ( - regexList.length !== 0 - && apiIgnoreValueRegex.test(obj.default) === false - && regexList.every(regex => regex.test(obj.default) === false) - ) { - logError(`${ banner } object: "default" value must satisfy regex: ${ regexList.map(r => r.toString()).join(' or ') }`) - console.error(obj) - console.log() - process.exit(1) - } - } - if (obj.values) { if (obj.values.some(val => typeof val !== 'string')) { - logError(`${ banner } object: stringify each of "values" entries`) - console.error(obj) - console.log() - process.exit(1) + printErrorAndExit('object: stringify each of "values" entries') } if (regexList.length !== 0) { @@ -381,21 +668,36 @@ function parseObject ({ banner, api, itemName, masterType, verifyCategory, verif apiIgnoreValueRegex.test(val) === false && regexList.every(regex => regex.test(val) === false) ) { - logError(`${ banner } object: "values" -> "${ val }" value must satisfy regex: ${ regexList.map(r => r.toString()).join(' or ') }`) - console.error(obj) - console.log() - process.exit(1) + printErrorAndExit( + `object: "values" -> "${ val }" value must satisfy regex: ` + + `${ regexList.map(r => r.toString()).join(' or ') }` + ) } }) } } - if (obj.examples) { + if (obj.hasOwnProperty('default')) { + if (typeof obj.default !== 'string') { + printErrorAndExit('object: stringify "default" value') + } + + if (apiIgnoreValueRegex.test(obj.default) === false) { + if (regexList.length !== 0 && regexList.every(regex => regex.test(obj.default) === false)) { + printErrorAndExit( + `object: "default" value must satisfy regex: ${ regexList.map(r => r.toString()).join(' or ') }` + ) + } + + if (obj.values && obj.values.includes(obj.default) === false) { + printErrorAndExit('object: "default" value must be one of the "values"') + } + } + } + + if (obj.examples !== void 0) { if (obj.examples.some(val => typeof val !== 'string')) { - logError(`${ banner } object: stringify each of "examples" entries`) - console.error(obj) - console.log() - process.exit(1) + printErrorAndExit('object: stringify each of "examples" entries') } if (regexList.length !== 0) { @@ -404,121 +706,94 @@ function parseObject ({ banner, api, itemName, masterType, verifyCategory, verif apiIgnoreValueRegex.test(val) === false && regexList.every(regex => regex.test(val) === false) ) { - logError(`${ banner } object: "examples" -> "${ val }" value must satisfy regex: ${ regexList.map(r => r.toString()).join(' or ') }`) - console.error(obj) - console.log() - process.exit(1) + printErrorAndExit( + `object: "examples" -> "${ val }" value must satisfy regex: ${ regexList.map(r => r.toString()).join(' or ') }` + ) } }) } if ((new Set(obj.examples)).size !== obj.examples.length) { - logError(`${ banner } object has "examples" Array with duplicates`) - console.error(obj) - console.log() - process.exit(1) + printErrorAndExit('object has "examples" Array with duplicates') } } - // Since we processed '__exemption', we can strip it - if (obj.__exemption !== void 0) { - const { __exemption, ...p } = obj - api[ itemName ] = p + if ( + obj.hasOwnProperty('passthrough') === true + && passthroughValues.includes(obj.passthrough) === false + ) { + printErrorAndExit(`"passthrough" should be one of: ${ passthroughValues.join('|') }`) } - 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) - } - }) - def.isString && def.isString.forEach(prop => { - if (obj[ prop ] && typeof obj[ prop ] !== 'string') { - logError(`${ banner }/"${ prop }" is not a String`) - console.error(obj) - console.log() - process.exit(1) - } - }) - if (obj.default !== void 0 && obj.required === true) { - logError(`${ banner } cannot have "required" as true since it is optional because it has "default"`) - console.error(obj) - console.log() - process.exit(1) + if ( + Array.isArray(obj.type) === true + ? (obj.type.includes('Any') !== true && obj.type.includes('undefined') !== true) + : [ 'Any', 'undefined' ].includes(obj.type) !== true + ) { + printErrorAndExit( + 'cannot have "required" as true since it is optional because it has "default" ' + + '(if default is still required as it handles the "undefined" value, then ' + + 'add "__requireWithDefault": true)' + ) + } } - // If required is specified, use it, if not and it has a default value, then it's optional, otherwise use undefined so it can get overridden later - api[ itemName ].required = obj.required !== void 0 ? obj.required : obj.default !== void 0 ? false : undefined + // If required is specified, use it, if not and it has a default value, then it's optional, + // otherwise use undefined so it can get overridden later + api[ itemName ].required = obj.required !== void 0 + ? obj.required + : (obj.default !== void 0 ? false : undefined) } if (obj.tsType && obj.autoDefineTsType === true && !obj.definition) { - logError(`${ banner } object is auto defining "${ obj.tsType }" TS type but it is missing "definition" prop`) - console.error(obj) - console.log() - process.exit(1) + printErrorAndExit( + `object is auto defining "${ obj.tsType }" TS type but it is missing "definition" prop` + ) } if (masterType === 'props') { if (Array.isArray(obj.type) === true && (new Set(obj.type)).size !== obj.type.length) { - logError(`${ banner } object has "type" defined as Array, but the Array contains duplicates`) - console.error(obj) - console.log() - process.exit(1) + printErrorAndExit( + 'object has "type" defined as Array, but the Array contains duplicates' + ) } if (itemName.indexOf('class') !== -1) { if (obj.type === 'Object' && obj.tsType !== 'VueClassObjectProp') { - logError(`${ banner } object is class-type (Object form) but "tsType" prop is set to "${ obj.tsType }" instead of "VueClassObjectProp":`) - console.error(obj) - console.log() - process.exit(1) + printErrorAndExit( + 'object is class-type (Object form) but "tsType" prop is set to ' + + `"${ obj.tsType }" instead of "VueClassObjectProp":` + ) } else if (obj.tsType !== 'VueClassProp' && isClassStyleType(obj.type) === true) { - logError(`${ banner } object is class-type (String/Array/Object form) but "tsType" prop is set to "${ obj.tsType }" instead of "VueClassProp":`) - console.error(obj) - console.log() - process.exit(1) + printErrorAndExit( + 'object is class-type (String/Array/Object form) but "tsType" prop ' + + `is set to "${ obj.tsType }" instead of "VueClassProp":` + ) } } else if (itemName.indexOf('style') !== -1) { if (obj.type === 'Object' && obj.tsType !== 'VueStyleObjectProp') { - logError(`${ banner } object is style-type (Object form) but "tsType" prop is set to "${ obj.tsType }" instead of "VueStyleObjectProp":`) - console.error(obj) - console.log() - process.exit(1) + printErrorAndExit( + 'object is style-type (Object form) but "tsType" prop is ' + + `set to "${ obj.tsType }" instead of "VueStyleObjectProp":` + ) } else if (obj.tsType !== 'VueStyleProp' && isClassStyleType(obj.type) === true) { - logError(`${ banner } object is style-type (String/Array/Object form) but "tsType" prop is set to "${ obj.tsType }" instead of "VueStyleProp":`) - console.error(obj) - console.log() - process.exit(1) + printErrorAndExit( + 'object is style-type (String/Array/Object form) but "tsType" prop ' + + `is set to "${ obj.tsType }" instead of "VueStyleProp":` + ) } } if (verifySerializable && obj.configFileType === undefined && isSerializable(obj) === false) { - logError(`${ banner } object's type is non-serializable but props in "quasarConfOptions" can only consist of ${ serializableTypes.join('/') } to be used in quasar.config file. Use "configFileType" prop to specify a serializable type for quasar.config file, or set to null if there is no suitable type:`) - console.error(obj) - console.log() - process.exit(1) + printErrorAndExit( + 'object\'s type is non-serializable but props in "quasarConfOptions" can only consist of ' + + `${ serializableTypes.join('/') } to be used in quasar.config file. Use "configFileType" ` + + 'prop to specify a serializable type for quasar.config file, or set to null if there is no suitable type:' + ) } } @@ -551,70 +826,42 @@ function parseObject ({ banner, api, itemName, masterType, verifyCategory, verif }) } -// https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string -// https://regex101.com/r/vkijKf/1/ -const SEMANTIC_REGEX = /^v(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 handleAddedIn (addedIn, banner) { - if (addedIn === void 0 || addedIn.length === 0) { - logError(`${ banner } "addedIn" is empty`) - console.log() - process.exit(1) - } - - if (addedIn.charAt(0) !== 'v') { - logError(`${ banner } "addedIn" value (${ addedIn }) must start with "v"`) - process.exit(1) - } - - if (SEMANTIC_REGEX.test(addedIn) !== true) { - logError(`${ banner } "addedIn" value (${ addedIn }) must follow semantic versioning`) - process.exit(1) - } -} - function parseAPI (file, apiType) { let api = readJsonFile(file) if (api.mixins !== void 0) { - api = getMixedInAPI(api, file) + api = getApiWithMixins(api, file) } const banner = `build.api.js: ${ relativeToRoot(file) } -> ` + const printErrorAndExit = msg => { + logError(`${ banner } ${ msg }`) + console.log() + process.exit(1) + } if (api.meta === void 0 || api.meta.docsUrl === void 0) { - logError(`${ banner } API file does not contain meta > docsUrl`) - process.exit(1) + printErrorAndExit('API file does not contain meta > docsUrl') } // "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 (!topSections[ apiType ].rootProps.includes(type)) { + printErrorAndExit(` "${ type }" is not recognized for a ${ apiType }`) } - if (type === 'injection') { - if (typeof api.injection !== 'string' || api.injection.length === 0) { - logError(`${ banner } "${ type }"/"injection" invalid content`) - process.exit(1) + if (api.hasOwnProperty(type) === true) { + const result = topSections[ apiType ].rootValidations[ type ](api[ type ]) + if (result !== true) { + printErrorAndExit(result) } - continue - } - - if (type === 'addedIn') { - handleAddedIn(api.addedIn, 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) - } - } + const handledTypes = [ 'addedIn', 'injection' ] - if ([ 'meta', 'quasarConfOptions' ].includes(type)) { + for (const type of [ 'meta', 'quasarConfOptions' ]) { + if (api[ type ] !== void 0) { parseObject({ banner: `${ banner } "${ type }"`, api, @@ -622,25 +869,32 @@ function parseAPI (file, apiType) { masterType: type, verifySerializable: type === 'quasarConfOptions' }) - continue } + } + handledTypes.push('meta', 'quasarConfOptions') - if ([ 'value', 'arg' ].includes(type)) { + for (const type of [ 'value', 'arg' ]) { + if (api[ type ] !== void 0) { parseObject({ banner: `${ banner } "${ type }"`, api, itemName: type, masterType: 'props' }) - continue } + } + handledTypes.push('value', 'arg') - const isComponent = banner.indexOf('component') > -1 + const isComponent = banner.indexOf('component') !== -1 - for (const itemName in api[ type ]) { + for (const type in api) { + const targetApi = api[ type ] + if (handledTypes.includes(type) === true) continue + + for (const itemName in targetApi) { parseObject({ banner: `${ banner } "${ type }"/"${ itemName }"`, - api: api[ type ], + api: targetApi, itemName, masterType: type === 'computedProps' ? 'props' : type, verifyCategory: type === 'props' && isComponent @@ -652,11 +906,9 @@ function parseAPI (file, apiType) { } function orderAPI (api, apiType) { - const ordered = { - type: apiType - } + const ordered = { type: apiType } - topSections[ apiType ].forEach(section => { + topSections[ apiType ].rootProps.forEach(section => { if (api[ section ] !== void 0) { ordered[ section ] = api[ section ] } @@ -665,139 +917,337 @@ function orderAPI (api, apiType) { 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(`[1] ${ name }: wrong definition for prop "${ key }" on "${ property }": expected ${ expectedVal } but found ${ apiVal }`) - return true - } -} - function fillAPI (apiType, list, encodeFn) { - return file => { + return async file => { const name = basename(file) const filePath = join(dest, name) - const api = orderAPI(parseAPI(file, apiType), apiType) + 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', '.js') + const componentPath = file.replace('.json', '.js') + const componentName = name.replace('.json', '.js') + const componentContent = fse.readFileSync(componentPath, 'utf-8') - const definition = fse.readFileSync(filePath, 'utf-8') + let RuntimeComponent - 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]') + try { + const comp = await import(componentPath) + RuntimeComponent = comp.default + } + catch (err) { + logError(`${ componentName }: failed to import Component file; check if it is a valid ES module`) + console.error(err) + process.exit(1) + } + + const apiProps = api.props || {} + const apiEvents = api.events || {} + const apiSlots = api.slots || {} + + const runtimeProps = RuntimeComponent.props || {} + const runtimeEmits = RuntimeComponent.emits || [] + + let match + + while ((match = slotRegex.exec(componentContent)) !== null) { + const slotName = (match[ 1 ] || match[ 2 ]).replace(/(\${.+})/g, '[name]') + + if (apiSlots[ slotName ] === void 0) { + logError(`${ name }: missing "slot" -> "${ slotName }" definition (found slots usage with it)`) + hasError = true + } + } + + while ((match = emitRegex.exec(componentContent)) !== null) { + const matchedEmit = match[ 1 ] + const emitName = kebabCase(deCapitalize(matchedEmit)) // deCapitalize because: QTable > emit('RowClick') + const propName = `on${ capitalize(matchedEmit) }` + + if ( + runtimeEmits.includes(matchedEmit) === false + && runtimeProps[ propName ] === void 0 + ) { + logError( + `${ componentName }: Component is emitting "${ matchedEmit }" event without having ` + + 'it defined in its code; Solutions:' + + `\n 1. add it in the Component as "emits: [ '${ matchedEmit }' ]"` + + `\n 2. or as "props: { ${ propName }: ... }"` + ) + hasError = true + } + + if (apiEvents[ emitName ] === void 0) { + logError(`${ name }: missing "events" -> "${ emitName }" definition (found emit() with it)`) + hasError = true + } + } + + // runtime props should be defined in the API + for (const runtimePropName in runtimeProps) { + const apiPropName = kebabCase(runtimePropName) + const apiEntry = apiProps[ apiPropName ] + + if (/^on[A-Z]/.test(runtimePropName) === true) { + const strippedPropName = runtimePropName.slice(2) // strip "on" prefix + const runtimeEmitName = deCapitalize(strippedPropName) + const apiEventName = kebabCase(strippedPropName) + + // should not duplicate as prop and emit + if (runtimeEmits.includes(runtimeEmitName) === true) { + logError( + `${ componentName }: Component has duplicated prop (${ runtimePropName }) + ` + + `emit (${ runtimeEmitName }); only one should be defined` + ) + hasError = true + } - 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 + if (apiEntry !== void 0) { + logError( + `${ name }: "props" -> "${ apiPropName }" should instead be defined ` + + `as "events" -> "${ apiEventName }"` + ) + hasError = true } - else if (api.slots[ slotName ].internal === true) { - delete api.slots[ slotName ] + + if (apiEvents[ apiEventName ] === void 0) { + logError( + `${ name }: missing "events" -> "${ apiEventName }" definition ` + + `(found Component prop "${ runtimePropName }")` + ) + hasError = true } + + continue } - astEvaluate(definition, topSections[ apiType ], (prop, key, definition) => { - if (prop === 'props') { - if (!key && ('' + definition.type) === 'Function,Array') { - // TODO - // wrong evaluation; example: QTabs: props > 'onUpdate:modelValue' - return - } + const runtimePropEntry = runtimeProps[ runtimePropName ] - key = key.replace(/([a-z])([A-Z])/g, '$1-$2') - .replace(/\s+/g, '-') - .toLowerCase() + if (apiEntry === void 0) { + logError( + `${ name }: missing "props" -> "${ apiPropName }" definition ` + + `(found Component prop "${ runtimePropName }")` + ) + hasError = true + } + else if (apiEntry.passthrough === 'child') { + if ( + Object(runtimePropEntry) !== runtimePropEntry + || Object.keys(runtimePropEntry).length !== 0 + ) { + logError( + `${ name }: "props" -> "${ apiPropName }" is marked as ` + + 'passthrough="child" but its definition is NOT an empty Object' + ) + console.log(apiEntry) + hasError = true + } + } + else { + const apiTypes = Array.isArray(apiEntry.type) ? apiEntry.type : [ apiEntry.type ] - if (/^on-/.test(key) === true) return + const { + runtimeTypes, + isRuntimeRequired, + hasRuntimeDefault, + runtimeDefaultValue + } = extractRuntimePropAttrs(runtimePropEntry) + + const isRuntimeFunction = runtimeTypes.length === 1 && runtimeTypes[ 0 ] === 'Function' + const runtimeDefinableApiTypes = extractRuntimeDefinablePropTypes(apiTypes) + + // API "type" validation against runtime + if ( + runtimeDefinableApiTypes.length !== runtimeTypes.length + || runtimeDefinableApiTypes.every((t, i) => t === runtimeTypes[ i ]) === false + ) { + logError( + `${ name }: wrong definition for prop "${ apiPropName }" - ` + + `JSON as ${ JSON.stringify(apiTypes) } ` + + `vs Component as ${ JSON.stringify(runtimeTypes) }` + ) + console.log(apiEntry) + hasError = true } - 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 + // API "required" validation against runtime + if (isRuntimeRequired === true && apiEntry.required !== true) { + logError(`${ name }: "props" -> "${ apiPropName }" is missing the required=true flag`) + console.log(apiEntry) + hasError = true } - if (definition) { - const propApi = api[ prop ][ key ] - if (typeof definition === 'string' && propApi.type !== definition) { - logError(`[2] ${ 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 - } + // API "default" value validation against runtime + if (hasRuntimeDefault === true) { + if (apiEntry.hasOwnProperty('default') === false) { + logError( + `${ name }: "props" -> "${ apiPropName }" is missing "default" with ` + + `value: "${ encodeDefaultValue(runtimeDefaultValue, isRuntimeFunction) }"` + ) + console.log(apiEntry) + hasError = true } - else { - if (definition.type) { - let propApiType - - // null is implicit for Vue, so we normalize the type - // so the other validations won't break - if ( - key === 'model-value' - && Array.isArray(propApi.type) - && (propApi.type.includes('null') || propApi.type.includes('undefined')) - ) { - propApiType = propApi.type.filter(v => v !== 'null' && v !== 'undefined') - if (propApiType.length === 1) { - propApiType = propApiType[ 0 ] + else if (apiIgnoreValueRegex.test(apiEntry.default) === false) { + const encodedValue = encodeDefaultValue(runtimeDefaultValue, isRuntimeFunction) + + if (apiEntry.default !== encodedValue) { + let handledAlready = false + + if (isRuntimeFunction === true) { + const fn = runtimeDefaultValue.toString() + + if (fn.indexOf('\n') !== -1) { + logError( + `${ componentName }: prop "${ runtimePropName }" -> "default" ` + + 'should be a single line arrow function (found multiple lines)' + ) + console.log(apiEntry) + hasError = true + handledAlready = true } - } - else { - propApiType = propApi.type - } - if (Array.isArray(definition.type)) { - const pApi = key === 'model-value' - ? { ...propApi, type: propApiType } - : propApi + if (handledAlready === false && functionRE.test(fn) === false) { + logError( + `${ componentName }: prop "${ runtimePropName }" -> "default" should ` + + 'be an arrow function that begins with: "() => "' + ) + console.log(apiEntry) + hasError = true + } - if (arrayHasError(name, key, 'type', definition.type, pApi)) { + if (handledAlready === false && /^[a-zA-Z]/.test(encodedValue) === true) { + logError( + `${ componentName }: prop "${ runtimePropName }" -> "default" should ` + + 'be an arrow factory function that does not reference any external variables' + ) + console.log(apiEntry) hasError = true } } - else if (propApiType !== definition.type) { - logError(`[3] ${ 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 (handledAlready === false && apiEntry.__runtimeDefault !== true) { + logError( + `${ name }: "props" -> "${ apiPropName }" > "default" value should ` + + `be: "${ encodedValue }" (instead of "${ apiEntry.default }")` + ) + console.log(apiEntry) + hasError = true } } - if (key !== 'model-value' && definition.required && Boolean(definition.required) !== propApi.required) { - logError(`[4] ${ 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 (apiEntry.__runtimeDefault === true && runtimeDefaultValue !== null) { + logError( + `${ name }: "props" -> "${ apiPropName }" should NOT ` + + 'have "__runtimeDefault" (found static value on Component)' + ) + console.log(apiEntry) + hasError = true } + } + } + else if (apiEntry.__runtimeDefault !== true && apiEntry.hasOwnProperty('default') === true) { + logError( + `${ name }: "props" -> "${ apiPropName }" should NOT have a "default" value; Solutions:` + + '\n 1. remove "default" because it should indeed not have it' + + '\n 2. it is runtime computed, in which case add "__runtimeDefault": true' + + '\n 3. it handles the "undefined" value, in which case add "undefined" or "Any" to the "type"' + ) + console.log(apiEntry) + hasError = true + } + } + } - if (definition.validator && Array.isArray(definition.validator)) { - const validator = definition.validator.map(entry => ( - typeof entry === 'string' - ? `'${ entry }'` - : entry - )) + // API defined props should exist in the component + for (const apiPropName in apiProps) { + const apiEntry = apiProps[ apiPropName ] + const runtimeName = pascalCase(apiPropName) + + if (apiEntry.passthrough === true) { + if (runtimeProps[ runtimeName ] !== void 0) { + logError( + `${ name }: "props" -> "${ apiPropName }" should NOT be ` + + 'a "passthrough" as it exists in the Component too' + ) + console.log(apiEntry) + hasError = true + } - if (arrayHasError(name, key, 'values', validator, propApi)) { - hasError = true // keep looping through to find as many as can be found before exiting - } - } - } + continue + } + + if (runtimeProps[ runtimeName ] === void 0) { + logError( + `${ name }: "props" -> "${ apiPropName }" is in JSON but ` + + 'not in the Component (is it a passthrough?)' + ) + console.log(apiEntry) + hasError = true + } + } + + // runtime emits should be defined in the API as events + for (const runtimeEmitName of runtimeEmits) { + const apiEventName = kebabCase(runtimeEmitName) + + if (apiEvents[ apiEventName ] === void 0) { + logError( + `${ name }: missing "events" -> "${ apiEventName }" definition ` + + `(found Component > emits: "${ runtimeEmitName }")` + ) + hasError = true + } + } + + // API defined events should exist in the component + for (const apiEventName in apiEvents) { + const apiEntry = apiEvents[ apiEventName ] + + const runtimeEmitName = pascalCase(apiEventName) + const runtimePropName = `on${ capitalize(runtimeEmitName) }` + + if (apiEntry.passthrough === true) { + if (runtimeProps[ runtimePropName ] !== void 0) { + logError( + `${ name }: "events" -> "${ apiEventName }" should NOT be ` + + 'a "passthrough" as it exists in the Component too' + ) + console.log(apiEntry) + hasError = true } - }) + + if (runtimeEmits.includes(runtimeEmitName) === true) { + logError( + `${ name }: "events" -> "${ apiEventName }" should NOT be a "passthrough" ` + + `as it exists in the Component (as emits: ${ runtimeEmitName })` + ) + console.log(apiEntry) + hasError = true + } + + continue + } + + if ( + runtimeProps[ runtimePropName ] === void 0 + && runtimeEmits.includes(runtimeEmitName) === false + ) { + logError( + `${ name }: "events" -> "${ apiEventName }" is in JSON but ` + + 'not in the Component (is it a passthrough?)' + ) + console.log(apiEntry) + hasError = true + } + } + + if (hasError === true) { + logError('Errors were found... exiting with error') + process.exit(1) } Object.keys(api).forEach(section => { @@ -805,17 +1255,37 @@ function fillAPI (apiType, list, encodeFn) { if (Object(target) === target) { for (const key in target) { - if (target[ key ]?.internal === true) { + const entry = target[ key ] + if (Object(entry) !== entry) continue + + if (entry.internal === true) { delete target[ key ] } + else if (entry.internal === false) { + // save bytes over the wire + delete entry.internal + } + + if ( + entry.hasOwnProperty('passthrough') === true + && entry.passthrough !== true + ) { + // save bytes over the wire + delete entry.passthrough + } + + if (entry.hasOwnProperty('__runtimeDefault') === true) { + // API internal prop; not needed in the final API + delete entry.__runtimeDefault + } + } + + // we might have only internal stuff in a key (which was deleted above) + if (Object.keys(target).length === 0) { + delete api[ section ] } } }) - - if (hasError === true) { - logError('Errors were found...exiting') - process.exit(1) - } } // copy API file to dest @@ -873,37 +1343,65 @@ function writeApiIndex (list, encodeFn) { ) } -export function generate ({ compact = false } = {}) { +function prepareRuntimeImports () { + // we prepare importing UI code so that it won't crash + global.__QUASAR_SSR__ = true + global.__QUASAR_SSR_SERVER__ = true + global.__QUASAR_SSR_CLIENT__ = false +} + +function resetRuntimeImports () { + // we revert the changes we did to global because + // we are done with importing the UI code + delete global.__QUASAR_SSR__ + delete global.__QUASAR_SSR_SERVER__ + delete global.__QUASAR_SSR_CLIENT__ +} + +export async function generate ({ compact = false } = {}) { const encodeFn = compact === true ? JSON.stringify : json => JSON.stringify(json, null, 2) - return new Promise((resolve) => { + prepareRuntimeImports() + + try { const list = [] - const plugins = glob.sync([ - 'src/plugins/*/*.json', - 'src/Brand.json' - ], { cwd: rootFolder, absolute: true }) - .map(fillAPI('plugin', list, encodeFn)) + const plugins = await Promise.all( + glob.sync([ + 'src/plugins/*/*.json', + 'src/Brand.json' + ], { cwd: rootFolder, absolute: true }) + .map(fillAPI('plugin', list, encodeFn)) + ) - const directives = glob - .sync('src/directives/*/*.json', { cwd: rootFolder, absolute: true }) - .map(fillAPI('directive', list, encodeFn)) + const directives = await Promise.all( + glob + .sync('src/directives/*/*.json', { cwd: rootFolder, absolute: true }) + .map(fillAPI('directive', list, encodeFn)) + ) - const components = glob - .sync('src/components/*/Q*.json', { cwd: rootFolder, absolute: true }) - .map(fillAPI('component', list, encodeFn)) + const components = await Promise.all( + glob + .sync('src/components/*/Q*.json', { cwd: rootFolder, absolute: true }) + .map(fillAPI('component', list, encodeFn)) + ) + + resetRuntimeImports() writeTransformAssetUrls(components, encodeFn) writeApiIndex(list, encodeFn) - resolve({ components, directives, plugins }) - }).catch(err => { + return { components, directives, plugins } + } + catch (err) { + resetRuntimeImports() + logError('build.api.js: something went wrong...') console.log() console.error(err) console.log() process.exit(1) - }) + } } diff --git a/ui/build/build.utils.js b/ui/build/build.utils.js index 1737ce9dc2a..8f68ba1ee56 100644 --- a/ui/build/build.utils.js +++ b/ui/build/build.utils.js @@ -5,7 +5,6 @@ import zlib from 'zlib' import { red, green, blue, magenta, gray, underline } from 'kolorist' import { table } from 'table' -const kebabRE = /[A-Z\u00C0-\u00D6\u00D8-\u00DE]/g const jsRE = /\.c?js$/ const cssRE = /\.(css|sass)$/ const tsRE = /\.ts$/ @@ -13,6 +12,30 @@ const jsonRE = /\.json$/ const tableData = [] +export function plural (num) { + return num === 1 ? '' : 's' +} + +const pascalRE = /((-|\.)\w)/g +const pascalInnerRE = /-|\./ +export function pascalCase (str) { + // assumes kebab case "str" + return str.replace( + pascalRE, + text => text.replace(pascalInnerRE, '').toUpperCase() + ) +} + +const kebabRE = /([a-zA-Z])([A-Z])/g +export function kebabCase (str) { + // assumes pascal case "str" + return str.replace(kebabRE, '$1-$2').toLowerCase() +} + +export function capitalize (str) { + return str.charAt(0).toUpperCase() + str.slice(1) +} + export const rootFolder = fileURLToPath( new URL('..', import.meta.url) ) @@ -178,13 +201,6 @@ export function logError (err) { console.log() } -export function kebabCase (str) { - return str.replace( - kebabRE, - match => '-' + match.toLowerCase() - ).substring(1) -} - export function clone (data) { const str = JSON.stringify(data) diff --git a/ui/package.json b/ui/package.json index 2e322db0e41..8a7509d7830 100644 --- a/ui/package.json +++ b/ui/package.json @@ -89,7 +89,6 @@ "open": "^10.1.0", "postcss-rtlcss": "^5.1.2", "prettier": "^3.2.5", - "recast": "^0.23.6", "sass-embedded": "^1.75.0", "table": "^6.8.2", "typescript": "^5.4.5", diff --git a/ui/src/components/breadcrumbs/QBreadcrumbs.json b/ui/src/components/breadcrumbs/QBreadcrumbs.json index d8186df8894..f0ee36c4ef3 100644 --- a/ui/src/components/breadcrumbs/QBreadcrumbs.json +++ b/ui/src/components/breadcrumbs/QBreadcrumbs.json @@ -35,9 +35,10 @@ "align": { "type": "String", - "default": "'left'", "desc": "Specify how to align the breadcrumbs horizontally", "values": [ "'left'", "'center'", "'right'", "'between'", "'around'", "'evenly'" ], + "default": "'left'", + "__runtimeDefault": true, "category": "content" } }, diff --git a/ui/src/components/btn-toggle/QBtnToggle.json b/ui/src/components/btn-toggle/QBtnToggle.json index fc285d04316..3bbc1ab196d 100644 --- a/ui/src/components/btn-toggle/QBtnToggle.json +++ b/ui/src/components/btn-toggle/QBtnToggle.json @@ -181,7 +181,9 @@ "clear": { "desc": "When using the 'clearable' property, this event is emitted when the already selected button is clicked" - } + }, + + "click": { "internal": true } }, "slots": { diff --git a/ui/src/components/btn/QBtn.json b/ui/src/components/btn/QBtn.json index 75f8e59ee4f..e3bc857414a 100644 --- a/ui/src/components/btn/QBtn.json +++ b/ui/src/components/btn/QBtn.json @@ -96,6 +96,11 @@ } } } - } + }, + + "touchstart": { "internal": true }, + "keydown": { "internal": true }, + "keyup": { "internal": true }, + "mousedown": { "internal": true } } } diff --git a/ui/src/components/card/QCardActions.json b/ui/src/components/card/QCardActions.json index f2ac25222e1..dbbd1f03351 100644 --- a/ui/src/components/card/QCardActions.json +++ b/ui/src/components/card/QCardActions.json @@ -8,6 +8,7 @@ "type": "String", "desc": "Specify how to align the actions; For horizontal mode, the default is 'left', while for vertical mode, the default is 'stretch'", "default": "# 'left'/'stretch'", + "__runtimeDefault": true, "values": [ "'left'", "'center'", "'right'", "'between'", "'around'", "'evenly'", "'stretch'" ], "category": "content" }, diff --git a/ui/src/components/carousel/QCarousel.json b/ui/src/components/carousel/QCarousel.json index 6a14e63ba69..a6fce0da903 100644 --- a/ui/src/components/carousel/QCarousel.json +++ b/ui/src/components/carousel/QCarousel.json @@ -74,6 +74,7 @@ "type": "String", "desc": "Side to stick navigation to", "default": "# 'bottom'/'right'", + "__runtimeDefault": true, "values": [ "'top'", "'right'", "'bottom'", "'left'" ], "category": "content" }, @@ -94,11 +95,13 @@ }, "transition-prev": { - "default": "'fade'" + "default": "'fade'", + "__delete": [ "__runtimeDefault" ] }, "transition-next": { - "default": "'fade'" + "default": "'fade'", + "__delete": [ "__runtimeDefault" ] } }, diff --git a/ui/src/components/color/QColor.json b/ui/src/components/color/QColor.json index 190d6655ce0..936b50fff47 100644 --- a/ui/src/components/color/QColor.json +++ b/ui/src/components/color/QColor.json @@ -39,6 +39,7 @@ "type": "Array", "desc": "Use a custom palette of colors for the palette tab", "default": "# hard-coded palette", + "__runtimeDefault": true, "examples": [ "[ '#019A9D', '#D9B801', 'rgb(23,120,0)', '#B2028A' ]" ], "category": "content" }, diff --git a/ui/src/components/date/QDate.json b/ui/src/components/date/QDate.json index 638a2ff5186..74ab44b61d8 100644 --- a/ui/src/components/date/QDate.json +++ b/ui/src/components/date/QDate.json @@ -147,6 +147,7 @@ "type": [ "String", "Number" ], "desc": "Sets the day of the week that is considered the first day (0 - Sunday, 1 - Monday, ...); This day will show in the left-most column of the calendar", "default": "# based on configured Quasar lang language", + "__runtimeDefault": true, "examples": [ "1", "# first-day-of-week=\"1\"", diff --git a/ui/src/components/dialog/QDialog.json b/ui/src/components/dialog/QDialog.json index 75044219c03..096165de131 100644 --- a/ui/src/components/dialog/QDialog.json +++ b/ui/src/components/dialog/QDialog.json @@ -117,6 +117,14 @@ "desc": "Allow elements outside of the Dialog to be focusable; By default, for accessibility reasons, QDialog does not allow outer focus", "category": "behavior", "addedIn": "v2.7.2" + }, + + "transition-show": { + "__runtimeDefault": true + }, + + "transition-hide": { + "__runtimeDefault": true } }, @@ -127,7 +135,9 @@ "escape-key": { "desc": "Emitted when ESC key is pressed; Does not get emitted if Dialog is 'persistent' or it has 'no-esc-key' set" - } + }, + + "click": { "internal": true } }, "methods": { diff --git a/ui/src/components/editor/QEditor.json b/ui/src/components/editor/QEditor.json index eb24f65522d..36037881ac9 100644 --- a/ui/src/components/editor/QEditor.json +++ b/ui/src/components/editor/QEditor.json @@ -190,6 +190,7 @@ "type": "String", "desc": "Toolbar background color (from Quasar Palette)", "default": "'grey-3'", + "__runtimeDefault": true, "examples": [ "'secondary'", "'blue-3'" ], "category": "toolbar" }, @@ -306,7 +307,12 @@ "link-hide": { "desc": "Emitted when the toolbar for editing a link is hidden", "addedIn": "v2.11.9" - } + }, + + "keydown": { "internal": true }, + "click": { "internal": true }, + "blur": { "internal": true }, + "focus": { "internal": true } }, "methods": { diff --git a/ui/src/components/expansion-item/QExpansionItem.json b/ui/src/components/expansion-item/QExpansionItem.json index 8fa9e7d19ca..d79341f9b74 100644 --- a/ui/src/components/expansion-item/QExpansionItem.json +++ b/ui/src/components/expansion-item/QExpansionItem.json @@ -238,6 +238,10 @@ "after-hide": { "extends": "after-hide" + }, + + "click": { + "internal": true } } } diff --git a/ui/src/components/footer/QFooter.json b/ui/src/components/footer/QFooter.json index 1c8c997b418..130402eddc6 100644 --- a/ui/src/components/footer/QFooter.json +++ b/ui/src/components/footer/QFooter.json @@ -52,6 +52,8 @@ "desc": "New 'reveal' state" } } - } + }, + + "focusin": { "internal": true } } } diff --git a/ui/src/components/header/QHeader.json b/ui/src/components/header/QHeader.json index 68a95b91bd9..8341b6f237e 100644 --- a/ui/src/components/header/QHeader.json +++ b/ui/src/components/header/QHeader.json @@ -59,6 +59,8 @@ "desc": "New 'reveal' state" } } - } + }, + + "focusin": { "internal": true } } } diff --git a/ui/src/components/input/QInput.json b/ui/src/components/input/QInput.json index d3f01ea4137..6e521ca48ba 100644 --- a/ui/src/components/input/QInput.json +++ b/ui/src/components/input/QInput.json @@ -79,7 +79,6 @@ "events": { "update:model-value": { - "extends": "update:model-value", "params": { "value": { "type": [ "String", "Number", "null" ] @@ -87,36 +86,20 @@ } }, - "focus": { - "desc": "Emitted when component gets focused", - "params": { - "evt": { - "extends": "evt" - } - } - }, - - "blur": { - "desc": "Emitted when component loses focus", - "params": { - "evt": { - "extends": "evt" - } - } - } + "click": { "internal": true }, + "paste": { "internal": true }, + "animationend": { "internal": true }, + "change": { "internal": true }, + "keydown": { "internal": true } }, "methods": { "focus": { - "desc": "Focus underlying input tag", - "params": null, - "returns": null + "desc": "Focus underlying input tag" }, "blur": { - "desc": "Lose focus on underlying input tag", - "params": null, - "returns": null + "desc": "Lose focus on underlying input tag" }, "select": { diff --git a/ui/src/components/item/QItem.json b/ui/src/components/item/QItem.json index 476224344ed..1675b3b7de5 100644 --- a/ui/src/components/item/QItem.json +++ b/ui/src/components/item/QItem.json @@ -113,6 +113,8 @@ } } } - } + }, + + "keyup": { "internal": true } } } diff --git a/ui/src/components/menu/QMenu.json b/ui/src/components/menu/QMenu.json index 5acaad85328..f48efdeb9be 100644 --- a/ui/src/components/menu/QMenu.json +++ b/ui/src/components/menu/QMenu.json @@ -129,7 +129,9 @@ "events": { "escape-key": { "desc": "Emitted when ESC key is pressed; Does not get emitted if Menu is 'persistent'" - } + }, + + "click": { "internal": true } }, "methods": { diff --git a/ui/src/components/page-scroller/QPageScroller.json b/ui/src/components/page-scroller/QPageScroller.json index b6f76bb2dbf..2698dd8e867 100644 --- a/ui/src/components/page-scroller/QPageScroller.json +++ b/ui/src/components/page-scroller/QPageScroller.json @@ -30,5 +30,9 @@ "default": "[ 18, 18 ]", "category": "content" } + }, + + "events": { + "click": { "internal": true } } } diff --git a/ui/src/components/pagination/QPagination.json b/ui/src/components/pagination/QPagination.json index dc2989a4974..30d242ba62d 100644 --- a/ui/src/components/pagination/QPagination.json +++ b/ui/src/components/pagination/QPagination.json @@ -161,7 +161,8 @@ "active-color": { "extends": "color", "desc": "Color name from the Quasar Color Palette for the ACTIVE button", - "default": "'primary'" + "default": "'primary'", + "__runtimeDefault": true }, "active-text-color": { @@ -191,6 +192,7 @@ "type": "String", "desc": "Apply custom gutter; Size in CSS units, including unit name or standard size name (none|xs|sm|md|lg|xl)", "default": "'2px'", + "__runtimeDefault": true, "examples": [ "'16px'", "'10px 5px'", "'2rem'", "'xs'", "'md lg'", "'2px 2px 5px 7px'" ], "category": "style", "addedIn": "v2.10" @@ -230,6 +232,7 @@ "type": [ "Boolean", "Object", "null" ], "desc": "Configure buttons material ripple (disable it by setting it to 'false' or supply a config object); Does not applies to boundary and ellipsis buttons", "default": "true", + "__runtimeDefault": true, "examples": [ "false", "{ early: true, center: true, color: 'teal', keyCodes: [] }" ], "category": "style" } diff --git a/ui/src/components/select/QSelect.json b/ui/src/components/select/QSelect.json index 09acb630b67..52a6eca559a 100644 --- a/ui/src/components/select/QSelect.json +++ b/ui/src/components/select/QSelect.json @@ -47,6 +47,7 @@ "type": [ "Function", "String" ], "desc": "Property of option which holds the 'value'; If using a function then for best performance, reference it from your scope and do not define it inline", "default": "'value'", + "__runtimeDefault": true, "params": { "option": { "type": [ "String", "Object" ], @@ -74,6 +75,7 @@ "type": [ "Function", "String" ], "desc": "Property of option which holds the 'label'; If using a function then for best performance, reference it from your scope and do not define it inline", "default": "'label'", + "__runtimeDefault": true, "params": { "option": { "type": [ "String", "Object" ], @@ -101,6 +103,7 @@ "type": [ "Function", "String" ], "desc": "Property of option which tells it's disabled; The value of the property must be a Boolean; If using a function then for best performance, reference it from your scope and do not define it inline", "default": "'disable'", + "__runtimeDefault": true, "params": { "option": { "type": [ "String", "Object" ], @@ -355,6 +358,10 @@ "name": { "desc": "Used to specify the name of the control; Useful if dealing with forms; If not specified, it takes the value of 'for' prop, if it exists" + }, + + "virtual-scroll-item-size": { + "__runtimeDefault": true } }, @@ -648,7 +655,11 @@ } } } - } + }, + + "keyup": { "internal": true }, + "keydown": { "internal": true }, + "keypress": { "internal": true } }, "methods": { diff --git a/ui/src/components/splitter/QSplitter.json b/ui/src/components/splitter/QSplitter.json index 9abf4ad7031..bd2c1898404 100644 --- a/ui/src/components/splitter/QSplitter.json +++ b/ui/src/components/splitter/QSplitter.json @@ -41,6 +41,7 @@ "type": "Array", "desc": "An array of two values representing the minimum and maximum split size of the two panels; When 'px' unit is set then you can use Infinity as the second value to make it unbound on the other side; Default value: for '%' unit it is [10, 90], while for 'px' unit it is [50, Infinity]", "default": "# [10, 90]/[50, Infinity]", + "__runtimeDefault": true, "examples": [ "[ 30, 70 ]", "[ 0, Infinity ]" ], "category": "content|model" }, diff --git a/ui/src/components/stepper/QStep.json b/ui/src/components/stepper/QStep.json index 8cb016e7f04..60f091cea37 100644 --- a/ui/src/components/stepper/QStep.json +++ b/ui/src/components/stepper/QStep.json @@ -94,5 +94,11 @@ "default": { "desc": "The content of the step; Can also contain a QStepperNavigation if you want to handle step navigation and don't have a global navigation in place" } + }, + + "events": { + "scroll": { + "internal": true + } } } diff --git a/ui/src/components/table/QTable.json b/ui/src/components/table/QTable.json index 10ae09bab8e..34d153379b9 100644 --- a/ui/src/components/table/QTable.json +++ b/ui/src/components/table/QTable.json @@ -41,13 +41,15 @@ }, "virtual-scroll-target": { - "extends": "scroll-target" + "extends": "scroll-target", + "passthrough": "child" }, "virtual-scroll-slice-size": { "type": [ "Number", "String", "null" ], "desc": "Minimum number of rows to render in the virtual list", "default": "30", + "passthrough": "child", "category": "virtual-scroll" }, @@ -55,6 +57,7 @@ "type": [ "Number", "String" ], "desc": "Ratio of number of rows in visible zone to render before it", "default": "1", + "passthrough": "child", "category": "virtual-scroll" }, @@ -62,6 +65,7 @@ "type": [ "Number", "String" ], "desc": "Ratio of number of rows in visible zone to render after it", "default": "1", + "passthrough": "child", "category": "virtual-scroll" }, @@ -69,6 +73,7 @@ "type": [ "Number", "String" ], "desc": "Default size in pixels of a row; This value is used for rendering the initial table; Try to use a value close to the minimum size of a row; Default value: 48 (24 if dense)", "default": "# 48/24", + "passthrough": "child", "category": "virtual-scroll" }, @@ -76,6 +81,7 @@ "type": [ "Number", "String" ], "desc": "Size in pixels of the sticky header (if using one); A correct value will improve scroll precision; Will be also used for non-virtual-scroll tables for fixing top alignment when using scrollTo method", "default": "0", + "passthrough": "child", "category": "virtual-scroll|behavior" }, @@ -83,13 +89,15 @@ "type": [ "Number", "String" ], "desc": "Size in pixels of the sticky footer part (if using one); A correct value will improve scroll precision", "default": "0", + "passthrough": "child", "category": "virtual-scroll" }, "table-colspan": { "type": [ "Number", "String" ], "desc": "The number of columns in the table (you need this if you use table-layout: fixed)", - "category": "virtual-scroll|content" + "category": "virtual-scroll|content", + "passthrough": "child" }, "color": { diff --git a/ui/src/components/table/QTh.json b/ui/src/components/table/QTh.json index 56e5745dd85..40675e8a631 100644 --- a/ui/src/components/table/QTh.json +++ b/ui/src/components/table/QTh.json @@ -22,5 +22,9 @@ "default": { "extends": "default" } + }, + + "events": { + "click": { "internal": true } } } diff --git a/ui/src/components/time/QTime.json b/ui/src/components/time/QTime.json index dcea277293f..d607117c5d4 100644 --- a/ui/src/components/time/QTime.json +++ b/ui/src/components/time/QTime.json @@ -24,6 +24,7 @@ "type": "String", "desc": "The default date to use (in YYYY/MM/DD format) when model is unfilled (undefined or null)", "default": "# current day", + "__runtimeDefault": true, "examples": [ "'1995/02/23'" ], "category": "model" }, @@ -31,6 +32,7 @@ "mask": { "type": [ "String", "null" ], "default": "'HH:mm'", + "__runtimeDefault": true, "examples": [ "'HH:mm:ss'", "'YYYY-MM-DD HH:mm:ss'", "'HH:mm MMMM Do, YYYY'" ] }, diff --git a/ui/src/composables/private.use-panel/use-panel.json b/ui/src/composables/private.use-panel/use-panel.json index b54b0a04197..fcd6c71ed2b 100644 --- a/ui/src/composables/private.use-panel/use-panel.json +++ b/ui/src/composables/private.use-panel/use-panel.json @@ -62,6 +62,7 @@ "extends": "transition", "desc": "One of Quasar's embedded transitions (has effect only if 'animated' prop is set)", "default": "# slide-right/slide-down", + "__runtimeDefault": true, "category": "transition" }, @@ -69,6 +70,7 @@ "extends": "transition", "desc": "One of Quasar's embedded transitions (has effect only if 'animated' prop is set)", "default": "# slide-left/slide-up", + "__runtimeDefault": true, "category": "transition" }, diff --git a/ui/testing/specs/specs.utils.js b/ui/testing/specs/specs.utils.js index ef3e0cbd41d..ec0f995c5d8 100644 --- a/ui/testing/specs/specs.utils.js +++ b/ui/testing/specs/specs.utils.js @@ -1,22 +1,22 @@ export const testIndent = ' ' -const pascalRegex = /((-|\.)\w)/g -const kebabRegex = /[A-Z\u00C0-\u00D6\u00D8-\u00DE]/g const ignoreKeyRE = /\.\.\./ const newlineRE = /\n/g +const pascalRE = /((-|\.)\w)/g +const pascalInnerRE = /-|\./ export function pascalCase (str) { + // assumes kebab case "str" return str.replace( - pascalRegex, - text => text.replace(/-|\./, '').toUpperCase() + pascalRE, + text => text.replace(pascalInnerRE, '').toUpperCase() ) } +const kebabRE = /([a-zA-Z])([A-Z])/g export function kebabCase (str) { - return str.replace( - kebabRegex, - match => '-' + match.toLowerCase() - ).substring(1) + // assumes pascal case "str" + return str.replace(kebabRE, '$1-$2').toLowerCase() } export function plural (num) {