Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add default detection for complex types #1649

Merged
merged 9 commits into from Jul 31, 2022
199 changes: 166 additions & 33 deletions packages/vue-component-meta/src/index.ts
Expand Up @@ -177,14 +177,23 @@ export function createComponentMetaChecker(tsconfigPath: string, checkerOptions:
}

// fill defaults
if (componentPath.endsWith('.vue') && exportName === 'default') {
const snapshot = host.getScriptSnapshot(componentPath)!;
const defaults = readCmponentDefaultProps(snapshot.getText(0, snapshot.getLength()));
for (const propName in defaults) {
const prop = result.find(p => p.name === propName);
if (prop) {
prop.default = defaults[propName];
}
const printer = ts.createPrinter(checkerOptions.printer);
const snapshot = host.getScriptSnapshot(componentPath)!;
const vueDefaults = componentPath.endsWith('.vue') && exportName === 'default' ? readVueComponentDefaultProps(snapshot.getText(0, snapshot.getLength()), printer) : {};
const tsDefaults = !componentPath.endsWith('.vue') ? readTsComponentDefaultProps(
componentPath.substring(componentPath.lastIndexOf('.') + 1), // ts | js | tsx | jsx
snapshot.getText(0, snapshot.getLength()),
exportName,
printer,
) : {};

for (const [propName, defaultExp] of Object.entries({
...vueDefaults,
...tsDefaults,
})) {
const prop = result.find(p => p.name === propName);
if (prop) {
prop.default = defaultExp;
}
}

Expand Down Expand Up @@ -274,8 +283,8 @@ export function createComponentMetaChecker(tsconfigPath: string, checkerOptions:
}

function createSchemaResolvers(typeChecker: ts.TypeChecker, symbolNode: ts.Expression, options: MetaCheckerSchemaOptions = {}) {
const ignore = options.ignore ?? [];
const enabled = options.enabled ?? false;
const enabled = !!options;
const ignore = typeof options === 'object' ? options.ignore ?? [] : [];

function shouldIgnore(subtype: ts.Type) {
const type = typeChecker.typeToString(subtype);
Expand Down Expand Up @@ -421,42 +430,166 @@ function createSchemaResolvers(typeChecker: ts.TypeChecker, symbolNode: ts.Expre
};
}

function readCmponentDefaultProps(fileText: string) {
function readVueComponentDefaultProps(vueFileText: string, printer: ts.Printer) {

const vueSourceFile = vue.createSourceFile('/tmp.vue', fileText, {}, {}, ts);
const descriptor = vueSourceFile.getDescriptor();
const scriptSetupRanges = vueSourceFile.getScriptSetupRanges();
const result: Record<string, string> = {};

if (descriptor.scriptSetup && scriptSetupRanges?.withDefaultsArg) {
scriptSetupWorker();
sciptWorker();

return result;

function scriptSetupWorker() {

const vueSourceFile = vue.createSourceFile('/tmp.vue', vueFileText, {}, {}, ts);
const descriptor = vueSourceFile.getDescriptor();
const scriptSetupRanges = vueSourceFile.getScriptSetupRanges();

if (descriptor.scriptSetup && scriptSetupRanges?.withDefaultsArg) {

const defaultsText = descriptor.scriptSetup.content.substring(scriptSetupRanges.withDefaultsArg.start, scriptSetupRanges.withDefaultsArg.end);
const ast = ts.createSourceFile('/tmp.' + descriptor.scriptSetup.lang, '(' + defaultsText + ')', ts.ScriptTarget.Latest);
const obj = findObjectLiteralExpression(ast);

const defaultsText = descriptor.scriptSetup.content.substring(scriptSetupRanges.withDefaultsArg.start, scriptSetupRanges.withDefaultsArg.end);
const ast = ts.createSourceFile('/tmp.' + descriptor.scriptSetup.lang, '(' + defaultsText + ')', ts.ScriptTarget.Latest);
const obj = findObjectLiteralExpression(ast);
if (obj) {
for (const prop of obj.properties) {
if (ts.isPropertyAssignment(prop)) {
const name = prop.name.getText(ast);
const exp = printer.printNode(ts.EmitHint.Expression, resolveDefaultOptionExpression(prop.initializer), ast);;
result[name] = exp;
}
}
}

if (obj) {
for (const prop of obj.properties) {
if (ts.isPropertyAssignment(prop)) {
const name = prop.name.getText(ast);
const exp = prop.initializer.getText(ast);
result[name] = exp;
function findObjectLiteralExpression(node: ts.Node) {
if (ts.isObjectLiteralExpression(node)) {
return node;
}
let result: ts.ObjectLiteralExpression | undefined;
node.forEachChild(child => {
if (!result) {
result = findObjectLiteralExpression(child);
}
});
return result;
}
}
}

function sciptWorker() {

const vueSourceFile = vue.createSourceFile('/tmp.vue', vueFileText, {}, {}, ts);
const descriptor = vueSourceFile.getDescriptor();

function findObjectLiteralExpression(node: ts.Node) {
if (ts.isObjectLiteralExpression(node)) {
return node;
if (descriptor.script) {
const scriptResult = readTsComponentDefaultProps(descriptor.script.lang, descriptor.script.content, 'default', printer);
for (const [key, value] of Object.entries(scriptResult)) {
result[key] = value;
}
let result: ts.ObjectLiteralExpression | undefined;
node.forEachChild(child => {
if (!result) {
result = findObjectLiteralExpression(child);
}
}
}

function readTsComponentDefaultProps(lang: string, tsFileText: string, exportName: string, printer: ts.Printer) {

const result: Record<string, string> = {};
const ast = ts.createSourceFile('/tmp.' + lang, tsFileText, ts.ScriptTarget.Latest);
const props = getPropsNode();

if (props) {
for (const prop of props.properties) {
if (ts.isPropertyAssignment(prop)) {
const name = prop.name?.getText(ast);
if (ts.isObjectLiteralExpression(prop.initializer)) {
for (const propOption of prop.initializer.properties) {
if (ts.isPropertyAssignment(propOption)) {
if (propOption.name?.getText(ast) === 'default') {
const _default = propOption.initializer;
result[name] = printer.printNode(ts.EmitHint.Expression, resolveDefaultOptionExpression(_default), ast);
}
}
}
}
});
return result;
}
}
}

return result;

function getComponentNode() {

let result: ts.Node | undefined;

if (exportName === 'default') {
ast.forEachChild(child => {
if (ts.isExportAssignment(child)) {
result = child.expression;
}
});
}
else {
ast.forEachChild(child => {
if (
ts.isVariableStatement(child)
&& child.modifiers?.some(mod => mod.kind === ts.SyntaxKind.ExportKeyword)
) {
for (const dec of child.declarationList.declarations) {
if (dec.name.getText(ast) === exportName) {
result = dec.initializer;
}
}
}
});
}

return result;
}

function getComponentOptionsNode() {

const component = getComponentNode();

if (component) {

// export default { ... }
if (ts.isObjectLiteralExpression(component)) {
return component;
}
// export default defineComponent({ ... })
// export default Vue.extend({ ... })
else if (ts.isCallExpression(component)) {
if (component.arguments.length) {
const arg = component.arguments[0];
if (ts.isObjectLiteralExpression(arg)) {
return arg;
}
}
}
}
}

function getPropsNode() {
const options = getComponentOptionsNode();
const props = options?.properties.find(prop => prop.name?.getText(ast) === 'props');
if (props && ts.isPropertyAssignment(props)) {
if (ts.isObjectLiteralExpression(props.initializer)) {
return props.initializer;
}
}
}
}

function resolveDefaultOptionExpression(_default: ts.Expression) {
if (ts.isArrowFunction(_default)) {
if (ts.isBlock(_default.body)) {
return _default; // TODO
}
else if (ts.isParenthesizedExpression(_default.body)) {
return _default.body.expression;
}
else {
return _default.body;
}
}
return _default;
}
4 changes: 2 additions & 2 deletions packages/vue-component-meta/src/types.ts
Expand Up @@ -40,11 +40,11 @@ export type PropertyMetaSchema = string
| { kind: 'event', type: string, schema?: PropertyMetaSchema[]; }
| { kind: 'object', type: string, schema?: Record<string, PropertyMeta>; };

export interface MetaCheckerSchemaOptions {
enabled?: boolean;
export type MetaCheckerSchemaOptions = boolean | {
ignore?: string[];
}
export interface MetaCheckerOptions {
schema?: MetaCheckerSchemaOptions;
forceUseTs?: boolean;
printer?: import('typescript').PrinterOptions;
}
81 changes: 73 additions & 8 deletions packages/vue-component-meta/tests/index.spec.ts
Expand Up @@ -7,12 +7,18 @@ describe(`vue-component-meta`, () => {
const tsconfigPath = path.resolve(__dirname, '../../vue-test-workspace/vue-component-meta/tsconfig.json');
const checker = createComponentMetaChecker(tsconfigPath, {
forceUseTs: true,
printer: {
newLine: 1,
},
});
const checker_schema = createComponentMetaChecker(tsconfigPath, {
schema: {
enabled: true,
ignore: ['MyIgnoredNestedProps'],
}
},
printer: {
newLine: 1,
},
});

test('global-props', () => {
Expand Down Expand Up @@ -51,6 +57,7 @@ describe(`vue-component-meta`, () => {
const foo = meta.props.find(prop => prop.name === 'foo');
const bar = meta.props.find(prop => prop.name === 'bar');
const baz = meta.props.find(prop => prop.name === 'baz');
const bazWithDefault = meta.props.find(prop => prop.name === 'bazWithDefault');
const union = meta.props.find(prop => prop.name === 'union');
const unionOptional = meta.props.find(prop => prop.name === 'unionOptional');
const nested = meta.props.find(prop => prop.name === 'nested');
Expand Down Expand Up @@ -101,14 +108,19 @@ describe(`vue-component-meta`, () => {
});

expect(baz).toBeDefined();
expect(baz?.required).toBeTruthy();
expect(baz?.type).toEqual('string[]');
// When initializing an array, users have to do it in a function to avoid
// referencing always the same instance for every component
// if no params are given to the function and it is simply an Array,
// the array is the default value and should be given instead of the function
expect(baz?.default).toEqual(`["foo", "bar"]`);
expect(baz?.required).toBeFalsy();
expect(baz?.type).toEqual('string[] | undefined');
expect(baz?.description).toEqual('string array baz');
expect(baz?.schema).toEqual({
kind: 'array',
type: 'string[]',
schema: ['string']
});
// expect(baz?.schema).toEqual({
// kind: 'array',
// type: 'string[]',
// schema: ['string']
// });

expect(union).toBeDefined();
expect(union?.required).toBeTruthy();
Expand Down Expand Up @@ -498,4 +510,57 @@ describe(`vue-component-meta`, () => {
expect(a).toBeDefined();
expect(b).toBeDefined();
});

test('options-api', () => {

const componentPath = path.resolve(__dirname, '../../vue-test-workspace/vue-component-meta/options-api/component.ts');
const meta = checker.getComponentMeta(componentPath);

// const submitEvent = meta.events.find(evt => evt.name === 'submit');

// expect(submitEvent).toBeDefined();
// expect(submitEvent?.schema).toEqual(expect.arrayContaining([{
// kind: 'object',
// schema: {
// email: {
// description: 'email of user',
// name: 'email',
// required: true,
// schema: 'string',
// tags: [],
// type: 'string'
// },
// password: {
// description: 'password of same user',
// name: 'password',
// required: true,
// schema: 'string',
// tags: [],
// type: 'string'
// }
// },
// type: 'SubmitPayload'
// }]));

const propNumberDefault = meta.props.find(prop => prop.name === 'numberDefault');

// expect(propNumberDefault).toBeDefined();
// expect(propNumberDefault?.type).toEqual('number | undefined');
// expect(propNumberDefault?.schema).toEqual({
// kind: 'enum',
// schema: ['undefined', 'number'],
// type: 'number | undefined'
// });
expect(propNumberDefault?.default).toEqual(`42`);

const propObjectDefault = meta.props.find(prop => prop.name === 'objectDefault');

expect(propObjectDefault).toBeDefined();
expect(propObjectDefault?.default).toEqual(`{\n foo: "bar"\n}`);

const propArrayDefault = meta.props.find(prop => prop.name === 'arrayDefault');

expect(propArrayDefault).toBeDefined();
expect(propArrayDefault?.default).toEqual(`[1, 2, 3]`);
});
});