Skip to content

Commit

Permalink
rework sfc-inspector to provide modular API
Browse files Browse the repository at this point in the history
  • Loading branch information
znck committed Aug 2, 2020
1 parent 07405c6 commit 1a837e2
Show file tree
Hide file tree
Showing 15 changed files with 964 additions and 201 deletions.
7 changes: 5 additions & 2 deletions packages/@vuedx/sfc-inspector/package.json
Expand Up @@ -27,9 +27,12 @@
"homepage": "https://github.com/znck/vue-developer-experience#readme",
"dependencies": {
"@babel/parser": "^7.10.5",
"@babel/types": "^7.10.5"
"@babel/traverse": "^7.11.0",
"@babel/types": "^7.10.5",
"@vue/compiler-core": "^3.0.0-rc.5",
"@vue/compiler-sfc": "^3.0.0-rc.5"
},
"devDependencies": {
"typescript": "^3.9.7"
}
}
}
67 changes: 67 additions & 0 deletions packages/@vuedx/sfc-inspector/src/analyzer.ts
@@ -0,0 +1,67 @@
import { Context, Plugin } from './types';
import { parse, SFCBlock } from '@vue/compiler-sfc';
import { createComponentInfoFactory } from './component';

const parsers: Context['parsers'] = {
sfc: {
sourceMap: false,
pad: 'space',
},

babel: {
sourceType: 'module',
plugins: ['bigInt', 'optionalChaining', 'optionalCatchBinding', 'nullishCoalescingOperator', 'objectRestSpread'],
},
};

export function createAnalyzer(plugins: Plugin[], options: Partial<Context['parsers']> = {}) {
function createContext(fileName: string, content: string): Context {
const { descriptor } = parse(content, { ...parsers.sfc, ...options.sfc, filename: fileName });

return {
fileName,
component: createComponentInfoFactory(),
descriptor,
plugins,
parsers: {
sfc: { ...parsers.sfc, ...options.sfc },
babel: { ...parsers.babel, ...options.babel },
},
};
}

function analyze(content: string, fileName: string = 'component.vue') {
const context = createContext(fileName, content);

processSFC(context);

return context.component.info();
}

function analyzeScript(content: string, fileName?: string) {
return analyze(`<script>${content}</script>`, fileName);
}

function analyzeTemplate(content: string, fileName?: string) {
return analyze(`<template>${content}</template>`, fileName);
}

return { analyze, analyzeScript, analyzeTemplate };
}

function processSFC(context: Context) {
const { script, template, styles, customBlocks } = context.descriptor;

function call<T extends SFCBlock>(kind: string, block: T) {
context.plugins.forEach(({ blocks }) => {
if (blocks && kind in blocks) {
blocks[kind](block, context);
}
});
}

if (script) call('script', script);
if (template) call('script', template);
styles.forEach(call.bind(null, 'style'));
customBlocks.forEach((block) => call(block.type, block));
}
174 changes: 174 additions & 0 deletions packages/@vuedx/sfc-inspector/src/analyzers/blockScriptAnalyzer.ts
@@ -0,0 +1,174 @@
import { parse } from '@babel/parser';
import traverse, { NodePath } from '@babel/traverse';
import { isIdentifier, ObjectExpression } from '@babel/types';
import { SFCScriptBlock } from '@vue/compiler-sfc';
import { Context, Plugin, ScriptAnalyzerContext } from '../types';
import { isNotNull } from '../utilities';

export const blockScriptAnalyzer: Plugin = {
blocks: {
script: (block, ctx) => {
if (block.content) {
processScript(createScriptContext(block.content, ctx, block));
}
},
},
};

export function createScriptContext(content: string, context: Context, block?: SFCScriptBlock): ScriptAnalyzerContext {
const script = block || {
type: 'script',
content: content,
setup: false,
attrs: {},
// TODO: Create loc object as if javascript file.
loc: { start: { offset: 0, line: 1, column: 1 }, end: { offset: 0, line: 1, column: 1 }, source: content },
};

const plugins = context.parsers.babel.plugins || [];

const ast = parse(content, {
...context.parsers.babel,
plugins: Array.from(new Set(plugins)),
});

return {
...context,
mode: script.setup ? 'setup' : 'module',
ast: ast,
block: script,
};
}

function processScript(context: ScriptAnalyzerContext) {
const enterHandlers = context.plugins
.map((plugin) => {
if (plugin.babel) {
if (typeof plugin.babel === 'function') {
return plugin.babel;
}
if ('enter' in plugin.babel) {
return plugin.babel.enter;
}
}
})
.filter(isNotNull);

const exitHandlers = context.plugins
.map((plugin) => {
if (plugin.babel && 'exit' in plugin.babel) {
return plugin.babel.exit;
}
})
.filter(isNotNull);

const setupHandlers = context.plugins
.map((plugin) => plugin.setup)
.filter(isNotNull)
.flat();
const optionsHandlers = context.plugins
.map((plugin) => (Array.isArray(plugin.options) ? plugin.options : null))
.filter(isNotNull)
.flat();
const optionsByNameHandlers = context.plugins
.map((plugin) => (Array.isArray(plugin.options) ? null : plugin.options))
.filter(isNotNull);
const declarationHandlers = context.plugins
.map((plugin) => plugin.declaration)
.filter(isNotNull)
.flat();

function call<T>(fns: ((node: T, context: ScriptAnalyzerContext) => void)[], node: T) {
fns.forEach((fn) => {
try {
fn(node, context);
} catch {
// TODO: Handle error.
}
});
}

function processOptions(options$: NodePath<ObjectExpression>) {
const properties$ = options$.get('properties');

properties$.forEach((property$) => {
if (property$.isObjectMember()) {
const { key } = property$.node;

if (isIdentifier(key)) {
const name = key.name;
optionsByNameHandlers.forEach((options) => {
const fn = options[name];

if (fn) {
try {
fn(property$, context);
} catch {
// TODO: Handler error.
}
}
});

if (property$.isObjectMethod() && name === 'setup') {
call(setupHandlers, property$ as any);
}
}
}
});
}

traverse(context.ast, {
enter(path) {
call(enterHandlers, path);
},
exit(path) {
call(exitHandlers, path);
},
ExportDefaultDeclaration(path) {
if (context.mode === 'setup') return;
const declaration$ = path.get('declaration');
/**
* Matches:
* export default {}
*/
if (declaration$.isObjectExpression()) {
call(declarationHandlers, declaration$);
call(optionsHandlers, declaration$);
processOptions(declaration$);
} else if (declaration$.isCallExpression()) {
/**
* Matches:
* export default fn(...)
*/
const { callee, arguments: args } = declaration$.node;
const args$ = declaration$.get('arguments');
let options$ = Array.isArray(args$) ? args$[0] : args$;

/**
* Matches:
* export default defineComponent(...)
*/
if (isIdentifier(callee) && callee.name === 'defineComponent') {
if (options$.isObjectExpression()) {
/**
* Matches:
* export default defineComponent({ ... })
*/
call(declarationHandlers, declaration$);
call(optionsHandlers, options$);
processOptions(options$);
} else if (options$.isFunctionExpression() || options$.isArrowFunctionExpression()) {
/**
* Matches:
* export default defineComponent(() => {...})
* export default defineComponent(function setup() {...})
*/
call(setupHandlers, options$ as any);
}
}
}
},
});
}


3 changes: 3 additions & 0 deletions packages/@vuedx/sfc-inspector/src/analyzers/index.ts
@@ -0,0 +1,3 @@
export * from './blockScriptAnalyzer';

export * from './scriptOptionsComponentsAnalyzer';
@@ -0,0 +1,95 @@
import { createAnalyzer } from '../analyzer';
import { blockScriptAnalyzer as ScriptBlockAnalyzer } from './blockScriptAnalyzer';
import { ComponentsOptionAnalyzer } from './scriptOptionsComponentsAnalyzer';

describe('script/options/components', () => {
const analyzer = createAnalyzer([ScriptBlockAnalyzer, ComponentsOptionAnalyzer]);

test('imported component in object export', () => {
const info = analyzer.analyzeScript(`
import Foo from './foo.vue'
import { Bar } from 'external-library'
import Baz from './baz.vue'
export default {
components: { Foo, Bar }
}
`);

expect(info.components).toHaveLength(2);
expect(info.components[0]).toMatchObject({
name: 'Foo',
kind: 'local',
source: {
moduleName: './foo.vue',
},
});
expect(info.components[1]).toMatchObject({
name: 'Bar',
kind: 'local',
source: {
moduleName: 'external-library',
exportName: 'Bar',
},
});
});

test('imported component in defineComponent', () => {
const info = analyzer.analyzeScript(`
import { defineComponent } from 'vue'
import Foo from './foo.vue'
import { Bar } from 'external-library'
import Baz from './baz.vue'
export default defineComponent({
components: { Foo, Bar }
})
`);

expect(info.components).toHaveLength(2);
expect(info.components[0]).toMatchObject({
name: 'Foo',
kind: 'local',
source: {
moduleName: './foo.vue',
},
});
expect(info.components[1]).toMatchObject({
name: 'Bar',
kind: 'local',
source: {
moduleName: 'external-library',
exportName: 'Bar',
},
});
});

test('imported component and registered with different name', () => {
const info = analyzer.analyzeScript(`
import Foo from './foo.vue'
import { Bar as LocalBar } from 'external-library'
import Baz from './baz.vue'
export default {
components: { MyFoo: Foo, MyBar: LocalBar }
}
`);

expect(info.components).toHaveLength(2);
expect(info.components[0]).toMatchObject({
name: 'MyFoo',
kind: 'local',
source: {
moduleName: './foo.vue',
},
});
expect(info.components[1]).toMatchObject({
name: 'MyBar',
kind: 'local',
source: {
moduleName: 'external-library',
exportName: 'Bar',
},
});
});
});

0 comments on commit 1a837e2

Please sign in to comment.