Skip to content

Commit

Permalink
feat: add default detection for complex types (#1649)
Browse files Browse the repository at this point in the history
Co-authored-by: johnsoncodehk <johnsoncodehk@gmail.com>
  • Loading branch information
elevatebart and johnsoncodehk committed Jul 31, 2022
1 parent 8802df8 commit 690d617
Show file tree
Hide file tree
Showing 6 changed files with 319 additions and 56 deletions.
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]`);
});
});

0 comments on commit 690d617

Please sign in to comment.