Skip to content

Commit

Permalink
fix(ast-vue): Handle js script deps via esbuild-plugin-ast
Browse files Browse the repository at this point in the history
  • Loading branch information
lgollut committed Jan 30, 2024
1 parent af9b9ac commit a04e0e9
Show file tree
Hide file tree
Showing 5 changed files with 126 additions and 56 deletions.
14 changes: 10 additions & 4 deletions packages/esbuild-plugin-ast-vue/README.md
Original file line number Diff line number Diff line change
@@ -1,23 +1,24 @@
# esbuild-plugin-ast
# esbuild-plugin-ast-vue

A plugin to generate an AST representation of your `.js` files. The plugin use [Acorn](https://github.com/acornjs/acorn) to produce an `estree` compliant `AST` object. You can then apply transformations by providing a `visitor` object.
A plugin to generate an AST representation of your `.vue` files. The plugin use [Acorn](https://github.com/acornjs/acorn) to produce an `estree` compliant `AST` object. You can then apply transformations by providing a `visitor` object. In order to also parse your `.js` dependencies imported in your scripts, you should probably use the [@liip/esbuild-plugin-ast](https://github.com/liip/class-prefixer/tree/main/packages/esbuild-plugin-ast) in conjunction with this plugin since `esbuild` does not allow plugin composition.

## Installation

```
npm i -D @liip/esbuild-plugin-ast
npm i -D @liip/esbuild-plugin-ast-vue @liip/esbuild-plugin-ast
```

## Usage

```javascript
import { astParser } from '@liip/esbuild-plugin-ast';
import { astParserVue } from '@liip/esbuild-plugin-ast-vue';

...

await esbuild.context({
...
plugins: [astParser(options)],
plugins: [astParserVue(vueParserOptions), astParser(parserOptions)],
...
});

Expand All @@ -31,6 +32,7 @@ You can configure the way the plugin works by setting different options.

```typescript
interface AstParserVueOptions extends AstParserOptions {
scriptNamespace?: string;
templateVisitor: AstParserOptions['visitor'];
templateOptions?: Pick<
SFCTemplateCompileOptions,
Expand All @@ -52,6 +54,10 @@ interface AstParserVueOptions extends AstParserOptions {
}
```

### scriptNamespace

A string namespace used to tell `@liip/esbuild-plugin-ast` to parse `.js` dependencies from your `.vue` files. This is required since `esbuild` does not allow plugin composition. This namespace should also be provided to `astParser` in order to work correctly.

### visitor

An `ESTraverse.Visitor` object used to apply AST transformations. Check the [Estraverse documentation](https://github.com/estools/estraverse) form more information on the available API.
Expand Down
1 change: 1 addition & 0 deletions packages/esbuild-plugin-ast-vue/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"hash-sum": "^2.0.0"
},
"peerDependencies": {
"@liip/esbuild-plugin-ast": "0.3.x",
"esbuild": "0.19.x"
}
}
147 changes: 99 additions & 48 deletions packages/esbuild-plugin-ast-vue/src/__tests__/plugin.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { mkdtemp, writeFile, rm, mkdir } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

import { astParser, AstParserOptions } from '@liip/esbuild-plugin-ast';
import { context, BuildContext, BuildOptions, OutputFile } from 'esbuild';

import { astParserVue, AstParserVueOptions } from '../plugin';
Expand All @@ -10,20 +11,23 @@ describe('astPluginVue', () => {
const placeholder = 'astPluginVue';
const argument = 'ast-plugin-vue';
const virtualPackage = 'ast-plugin-vue';

const pluginOptions: AstParserVueOptions = {
visitors: {
enter(node) {
if (
node.type === 'CallExpression' &&
node.callee.type === 'Identifier' &&
node.callee.name === placeholder
) {
return node.arguments[0];
}
return node;
},
const namespace = 'ast-parser-vue';

const visitors: AstParserOptions['visitors'] = {
enter(node) {
if (
node.type === 'CallExpression' &&
node.callee.type === 'Identifier' &&
node.callee.name === placeholder
) {
return node.arguments[0];
}
return node;
},
};

const vuePluginOptions: AstParserVueOptions = {
visitors,
templateVisitor: {
enter(node) {
if (
Expand All @@ -50,7 +54,6 @@ describe('astPluginVue', () => {
minify: false,
write: false,
external: ['vue'],
plugins: [astParserVue(pluginOptions)],
};

let tmpDir: string;
Expand All @@ -76,12 +79,6 @@ describe('astPluginVue', () => {
entryPoint,
`import testCase from './test-case.vue';\ntestCase();`,
);

ctx = await context({
...esbuildConfig,
entryPoints: { index: entryPoint },
minify: true,
});
});

afterEach(async () => {
Expand All @@ -93,49 +90,103 @@ describe('astPluginVue', () => {
await rm(tmpDir, { recursive: true, force: true });
});

describe('Script section', () => {
it('should correctly handle Vue dependencies containing astPlugin placeholder', async () => {
const testCase = `<script>export default { computed: { className() { return ${placeholder}('${argument}')}} }</script>`;
await writeFile(testFile, testCase);

const result = await ctx.rebuild();

expect((result.outputFiles as OutputFile[])[0].text).toEqual(
expect.stringContaining(argument),
);
expect((result.outputFiles as OutputFile[])[0].text).toEqual(
expect.not.stringContaining(placeholder),
);
describe('Standalone usage', () => {
beforeEach(async () => {
ctx = await context({
...esbuildConfig,
plugins: [astParserVue(vuePluginOptions)],
entryPoints: { index: entryPoint },
minify: true,
});
});

it('should correctly handle Vue dependencies containing astPlugin placeholder in script setup', async () => {
const testCase = `<script setup>import { ref } from 'vue'; const arg = ref(${placeholder}('${argument}'));</script>`;
await writeFile(testFile, testCase);
describe('Script section', () => {
it('should correctly handle Vue dependencies containing astPlugin placeholder', async () => {
const testCase = `<script>export default { computed: { className() { return ${placeholder}('${argument}')}} }</script>`;
await writeFile(testFile, testCase);

const result = await ctx.rebuild();

expect((result.outputFiles as OutputFile[])[0].text).toEqual(
expect.stringContaining(argument),
);
expect((result.outputFiles as OutputFile[])[0].text).toEqual(
expect.not.stringContaining(placeholder),
);
});

it('should correctly handle Vue dependencies containing astPlugin placeholder in script setup', async () => {
const testCase = `<script setup>import { ref } from 'vue'; const arg = ref(${placeholder}('${argument}'));</script>`;
await writeFile(testFile, testCase);

const result = await ctx.rebuild();

expect((result.outputFiles as OutputFile[])[0].text).toEqual(
expect.stringContaining(argument),
);
expect((result.outputFiles as OutputFile[])[0].text).toEqual(
expect.not.stringContaining(placeholder),
);
});

describe('Template section', () => {
it('should correctly handle Vue dependencies with static class in the template', async () => {
const testCase = `<template><div class="${placeholder}(${argument})"></div></template><script>export default {}</script>`;
await writeFile(testFile, testCase);

const result = await ctx.rebuild();

expect((result.outputFiles as OutputFile[])[0].text).toEqual(
expect.stringContaining(`class:"${argument}"`),
);
expect((result.outputFiles as OutputFile[])[0].text).toEqual(
expect.not.stringContaining(placeholder),
);
});
});
});
});

const result = await ctx.rebuild();
describe('Combined with esbuild-plugin-ast usage', () => {
beforeEach(async () => {
const pluginOptions: AstParserOptions = {
namespace,
visitors,
};

ctx = await context({
...esbuildConfig,
plugins: [
astParserVue({ scriptNamespace: namespace, ...vuePluginOptions }),
astParser(pluginOptions),
],
entryPoints: { index: entryPoint },
minify: true,
});
});

expect((result.outputFiles as OutputFile[])[0].text).toEqual(
expect.stringContaining(argument),
it('should correctly handle dependencies containing astPlugin placeholder in script', async () => {
const testDepFile = join(
tmpDir,
`./packages/${virtualPackage}/test-dep.js`,
);
expect((result.outputFiles as OutputFile[])[0].text).toEqual(
expect.not.stringContaining(placeholder),
);
});
});

describe('Template section', () => {
it('should correctly handle Vue dependencies with static class in the template', async () => {
const testCase = `<template><div class="${placeholder}(${argument})"></div></template><script>export default {}</script>`;
const testDep = `export function test() { return ${placeholder}('${argument}'); }`;
const testCase = `<script setup>import { test } from './test-dep.js'; test();</script>`;

await writeFile(testDepFile, testDep);
await writeFile(testFile, testCase);

const result = await ctx.rebuild();

expect((result.outputFiles as OutputFile[])[0].text).toEqual(
expect.stringContaining(`class:"${argument}"`),
expect.stringContaining(argument),
);
expect((result.outputFiles as OutputFile[])[0].text).toEqual(
expect.not.stringContaining(placeholder),
);

await rm(testDepFile);
});
});
});
15 changes: 13 additions & 2 deletions packages/esbuild-plugin-ast-vue/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ import { resolvePath, validateDependency } from './utils';

type ExtractNonArray<T> = T extends Array<any> ? never : T;

export interface AstParserVueOptions extends AstParserOptions {
export interface AstParserVueOptions
extends Omit<AstParserOptions, 'namespace'> {
templateOptions?: Pick<
SFCTemplateCompileOptions,
| 'compiler'
Expand All @@ -36,8 +37,11 @@ export interface AstParserVueOptions extends AstParserOptions {
| 'postcssPlugins'
>;
templateVisitor: ExtractNonArray<AstParserOptions['visitors']>;
scriptNamespace?: string;
}

type ScriptResolvedReturn = { path: string; namespace?: string };

validateDependency();

export function astParserVue({
Expand All @@ -46,6 +50,7 @@ export function astParserVue({
styleOptions,
visitors,
templateVisitor,
scriptNamespace,
}: AstParserVueOptions): Plugin {
return {
name: 'astParserVue',
Expand Down Expand Up @@ -73,9 +78,15 @@ export function astParserVue({
* them in a specific namespace
*/
build.onResolve({ filter: /\.vue\?type=script/ }, (args) => {
return {
const resolved: ScriptResolvedReturn = {
path: args.path,
};

if (scriptNamespace) {
resolved.namespace = scriptNamespace;
}

return resolved;
});

build.onLoad({ filter: /\.vue\?type=script/ }, (args) => {
Expand Down
5 changes: 3 additions & 2 deletions packages/esbuild-plugin-ast/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,17 @@ import type { Plugin, OnResolveArgs } from 'esbuild';
export interface AstParserOptions {
dependencies?: string[];
visitors: Visitor | Visitor[];
namespace?: string;
}

export function astParser({
dependencies,
visitors,
namespace = 'ast-parser',
}: AstParserOptions): Plugin {
return {
name: 'astParser',
setup(build) {
const namespace = 'ast-parser';
const excludeFileTypes = Object.keys(
build.initialOptions.loader || {},
).map((key) => key.slice(1));
Expand Down Expand Up @@ -109,7 +110,7 @@ export function astParser({

/**
* Load, parse and modify any other imports that was placed in
* the `phx-class-prefixer` namespace
* the `ast-parser` namespace
*/
build.onLoad({ filter: /.*/, namespace }, async (args) => {
const source = await readFile(args.path, 'utf-8');
Expand Down

0 comments on commit a04e0e9

Please sign in to comment.