Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"@types/semver": "^7.5.8",
"@vitest/coverage-v8": "^3.0.5",
"@volar/typescript": "^2.4.11",
"@vue/compiler-dom-types": "npm:@vue/compiler-dom@^3.5.13",
"@vue/language-core": "^2.2.2",
"@vue/language-core2.0": "npm:@vue/[email protected]",
"c8": "latest",
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

85 changes: 64 additions & 21 deletions src/loaders/vue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
LoaderResult,
OutputFile,
} from "../loader";
import { transpileVueTemplate } from "../utils/vue";

export interface DefineVueLoaderOptions {
blockLoaders?: {
Expand Down Expand Up @@ -61,33 +62,75 @@
const addOutput = (...files: OutputFile[]) => output.push(...files);

const blocks: VueBlock[] = [
sfc.descriptor.template,
...sfc.descriptor.styles,
...sfc.descriptor.customBlocks,
].filter((item) => !!item);
// merge script blocks
if (sfc.descriptor.script || sfc.descriptor.scriptSetup) {
// need to compile script when using typescript with <script setup>
if (sfc.descriptor.scriptSetup && sfc.descriptor.scriptSetup.lang) {
const merged = compileScript(sfc.descriptor, { id: input.srcPath });
merged.setup = false;
merged.attrs = toOmit(merged.attrs, "setup");
blocks.unshift(merged);

// we need to transpile script when using <script setup> and typescript
// and we need to transpile template because <template> can't access variables in setup after transpiled
const requireTranspile = !!sfc.descriptor.scriptSetup?.lang;

// we also need to remove typescript from template block
const requireTranspileTemplate = [
sfc.descriptor.script,
sfc.descriptor.scriptSetup,
].some((block) => !!block?.lang);

if (sfc.descriptor.template && !requireTranspile) {
if (requireTranspileTemplate) {
const transformed = await transpileVueTemplate(
// for lower version of @vue/compiler-sfc, `ast.source` is the whole .vue file
sfc.descriptor.template.content,
sfc.descriptor.template.ast,
async (code) => {
const res = await context.loadFile({
getContents: () => code,
path: `${input.path}.ts`,
srcPath: `${input.srcPath}.ts`,
extension: ".ts",
});

Check warning on line 91 in src/loaders/vue.ts

View check run for this annotation

Codecov / codecov/patch

src/loaders/vue.ts#L86-L91

Added lines #L86 - L91 were not covered by tests

return (
res.find((f) => [".js", ".mjs", ".cjs"].includes(f.extension))
?.contents || code

Check warning on line 95 in src/loaders/vue.ts

View check run for this annotation

Codecov / codecov/patch

src/loaders/vue.ts#L93-L95

Added lines #L93 - L95 were not covered by tests
);
},

Check warning on line 97 in src/loaders/vue.ts

View check run for this annotation

Codecov / codecov/patch

src/loaders/vue.ts#L97

Added line #L97 was not covered by tests
);
blocks.unshift({
type: "template",
content: transformed,
attrs: sfc.descriptor.template.attrs,
});
} else {
const scriptBlocks = [
sfc.descriptor.script,
sfc.descriptor.scriptSetup,
].filter((item) => !!item);
blocks.unshift(...scriptBlocks);
blocks.unshift(sfc.descriptor.template);
}
} else {
// push a fake script block to generate dts
blocks.unshift({
type: "script",
content: "export default {}",
attrs: {},
}

if (requireTranspile) {
// merge script blocks and template block
const merged = compileScript(sfc.descriptor, {
id: input.srcPath,
inlineTemplate: true,
});
fakeScriptBlock = true;
merged.setup = false;
merged.attrs = toOmit(merged.attrs, "setup");
blocks.unshift(merged);
} else {
const scriptBlocks = [
sfc.descriptor.script,
sfc.descriptor.scriptSetup,
].filter((item) => !!item);
blocks.unshift(...scriptBlocks);

if (scriptBlocks.length === 0) {
// push a fake script block to generate dts
blocks.unshift({
type: "script",
content: "export default {}",
attrs: {},
});
fakeScriptBlock = true;
}
}

const results = await Promise.all(
Expand Down
201 changes: 201 additions & 0 deletions src/utils/vue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
import type {
ParentNode,
ExpressionNode,
TemplateChildNode,
AttributeNode,
DirectiveNode,
SourceLocation,
RootNode,
} from "@vue/compiler-dom-types";

// copy from `@vue/compiler-dom`
enum NodeTypes {
ROOT,
ELEMENT,
TEXT,
COMMENT,
SIMPLE_EXPRESSION,
INTERPOLATION,
ATTRIBUTE,
DIRECTIVE,

// containers
COMPOUND_EXPRESSION,
IF,
IF_BRANCH,
FOR,
TEXT_CALL,

// codegen
VNODE_CALL,
JS_CALL_EXPRESSION,
JS_OBJECT_EXPRESSION,
JS_PROPERTY,
JS_ARRAY_EXPRESSION,
JS_FUNCTION_EXPRESSION,
JS_CONDITIONAL_EXPRESSION,
JS_CACHE_EXPRESSION,

// ssr codegen
JS_BLOCK_STATEMENT,
JS_TEMPLATE_LITERAL,
JS_IF_STATEMENT,
JS_ASSIGNMENT_EXPRESSION,
JS_SEQUENCE_EXPRESSION,
JS_RETURN_STATEMENT,
}

interface Expression {
loc: SourceLocation;
src: string;
replacement?: string;
}

function handleNode(
node:
| ParentNode
| ExpressionNode
| TemplateChildNode
| AttributeNode
| DirectiveNode
| undefined,
addExpression: (...expressions: Expression[]) => void,
) {
if (!node) {
return;
}

const search = (
node: ExpressionNode | TemplateChildNode | AttributeNode | DirectiveNode,
) => handleNode(node, addExpression);

switch (node.type) {
case NodeTypes.ROOT: {
for (const child of node.children) {
search(child);
}
return;
}
case NodeTypes.ELEMENT: {
const nodes = [...node.children, ...node.props];
for (const child of nodes) {
search(child);
}
return;
}
case NodeTypes.TEXT: {
return;
}
case NodeTypes.COMMENT: {
return;
}
case NodeTypes.SIMPLE_EXPRESSION: {
if (node.ast === null || node.ast === false) {
return;
}
addExpression({ loc: node.loc, src: node.content });
return;
}
case NodeTypes.INTERPOLATION: {
search(node.content);
return;
}
case NodeTypes.ATTRIBUTE: {
search(node.value);
return;
}
case NodeTypes.DIRECTIVE: {
const nodes = [
node.exp,
// node.arg,
// node.forParseResult?.source,
// node.forParseResult?.value,
// node.forParseResult?.key,
// node.forParseResult?.index,
...node.modifiers,
].filter((item) => !!item);
for (const child of nodes) {
search(child);
}
return;
}
case NodeTypes.COMPOUND_EXPRESSION: {
if (!node.ast) {
return;
}

Check warning on line 125 in src/utils/vue.ts

View check run for this annotation

Codecov / codecov/patch

src/utils/vue.ts#L123-L125

Added lines #L123 - L125 were not covered by tests

addExpression({ loc: node.loc, src: node.loc.source });
return;
}

Check warning on line 129 in src/utils/vue.ts

View check run for this annotation

Codecov / codecov/patch

src/utils/vue.ts#L127-L129

Added lines #L127 - L129 were not covered by tests
// case NodeTypes.IF:
// case NodeTypes.FOR:
// case NodeTypes.TEXT_CALL:
default: {
throw new Error(`Unexpected node type: ${node.type}`);
}

Check warning on line 135 in src/utils/vue.ts

View check run for this annotation

Codecov / codecov/patch

src/utils/vue.ts#L134-L135

Added lines #L134 - L135 were not covered by tests
}
}

export function replaceBySourceLocation(
src: string,
input: { loc: SourceLocation; replacement: string }[],
) {
if (input.length === 0) {
return src;
}

const data = [...input].sort(
(a, b) => b.loc.start.offset - a.loc.start.offset,
);
let result = src;
for (const { loc, replacement } of data) {
const start = loc.start.offset;
const end = loc.end.offset;
result = result.slice(0, start) + replacement + result.slice(end);
}

return result;
}

export async function transpileVueTemplate(
content: string,
root: RootNode,
transform: (code: string) => string | Promise<string>,
): Promise<string> {
const expressions: Expression[] = [];

handleNode(root, (...items) => expressions.push(...items));
await Promise.all(
expressions.map(async (item) => {
if (item.src.trim() === "") {
item.replacement = item.src;
return;
}

try {
// `{ key: val } as any` in `<div :style="{ key: val } as any" />` is a valid js snippet,
// but it can't be transformed.
// We can warp it with `()` to make it a valid js file
let res = (await transform(`(${item.src})`)).trim();

// result will be wrapped in `{content};\n`, we need to remove it
if (res.endsWith(";")) {
res = res.slice(0, -1);
}

item.replacement = res;
} catch {
item.replacement = item.src;
}
}),
);

const result = replaceBySourceLocation(
content,
expressions.filter((item) => !!item.replacement) as (Expression & {
replacement: string;
})[],
);

return result;
}
Loading
Loading