Skip to content

Commit

Permalink
feat(vue): new vue loader with vue/compiler-sfc
Browse files Browse the repository at this point in the history
  • Loading branch information
Teages committed Oct 6, 2024
1 parent 6026ea1 commit db97651
Show file tree
Hide file tree
Showing 3 changed files with 197 additions and 118 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
"peerDependencies": {
"sass": "^1.78.0",
"typescript": ">=5.5.4",
"vue": "^3.2.13",
"vue-tsc": "^1.8.27 || ^2.0.21"
},
"peerDependenciesMeta": {
Expand Down
262 changes: 180 additions & 82 deletions src/loaders/vue.ts
Original file line number Diff line number Diff line change
@@ -1,128 +1,226 @@
import type { Loader, LoaderResult } from "../loader";

export const vueLoader: Loader = async (input, context) => {
if (input.extension !== ".vue") {
return;
}
import type { SFCBlock } from "vue/compiler-sfc";
import type {
InputFile,
Loader,
LoaderContext,
LoaderResult,
OutputFile,
} from "../loader";

import fs from "node:fs";
import { dirname, resolve } from "pathe";

import { compileScript, parse } from "vue/compiler-sfc";

export interface DefineVueLoaderOptions {
blockLoaders?: {
[blockType: string]: VueBlockLoader | undefined;
};
}

const output: LoaderResult = [
{
path: input.path,
contents: await input.getContents(),
export interface VueBlockLoader {
(
block: Pick<SFCBlock, "type" | "content" | "attrs">,
context: LoaderContext & {
rawInput: InputFile;
addOutput: (...files: OutputFile[]) => void;
},
];

let earlyReturn = true;

for (const blockLoader of [styleLoader, scriptLoader]) {
const result = await blockLoader(
{ ...input, getContents: () => output[0].contents },
context,
);
if (!result) {
continue;
}

earlyReturn = false;
const [vueFile, ...files] = result;
output[0] = vueFile;
output.push(...files);
}

if (earlyReturn) {
return;
}

return output;
};
): Promise<Pick<SFCBlock, "type" | "content" | "attrs"> | undefined>;
}

interface BlockLoaderOptions {
export interface DefaultBlockLoaderOptions {
type: "script" | "style" | "template";
outputLang: string;
defaultLang?: string;
validExtensions?: string[];
exclude?: RegExp[];
}

const vueBlockLoader =
(options: BlockLoaderOptions): Loader =>
async (input, { loadFile }) => {
const contents = await input.getContents();

const BLOCK_RE = new RegExp(
`<${options.type}((\\s[^>\\s]*)*)>([\\S\\s.]*?)<\\/${options.type}>`,
"g",
);
export function defineVueLoader(options?: DefineVueLoaderOptions): Loader {
const blockLoaders = options?.blockLoaders || {};

const matches = [...contents.matchAll(BLOCK_RE)];
if (matches.length === 0) {
return async (input, context) => {
if (input.extension !== ".vue") {
return;
}

// TODO: support merging <script> blocks
if (options.type === "script" && matches.length > 1) {
let modified = false;

const raw = await input.getContents();
const sfc = parse(raw, { filename: input.path, ignoreEmpty: true });

if (sfc.errors.length > 0) {
for (const error of sfc.errors) {
console.error(error);
}

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

View check run for this annotation

Codecov / codecov/patch

src/loaders/vue.ts#L52-L54

Added lines #L52 - L54 were not covered by tests
return;
}

const [block, attributes = "", _, blockContents] = matches[0];
const output: LoaderResult = [];
const addOutput = (...files: OutputFile[]) => output.push(...files);

const blocks = [
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.path,
fs: createFs(input.srcPath),
});
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);
}
}

const results = await Promise.all(
blocks.map(async (data) => {
const blockLoader = blockLoaders[data.type];
const result = await blockLoader?.(data, {
...context,
rawInput: input,
addOutput,
});
if (result) {
modified = true;
}
return result || data;
}),
);

if (!block || !blockContents) {
if (!modified) {
return;
}

if (options.exclude?.some((re) => re.test(attributes))) {
const contents = results
.map((block) => {
const attrs = Object.entries(block.attrs)
.map(([key, value]) => {
if (!value) {
return undefined;
}

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

View check run for this annotation

Codecov / codecov/patch

src/loaders/vue.ts#L110-L111

Added lines #L110 - L111 were not covered by tests

return value === true ? key : `${key}="${value}"`;
})
.filter((item) => !!item)
.join(" ");

const header = `<${`${block.type} ${attrs}`.trim()}>`;
const footer = `</${block.type}>`;

return `${header}\n${cleanupBreakLine(block.content)}\n${footer}\n`;
})
.join("\n");
addOutput({
path: input.path,
srcPath: input.srcPath,
extension: ".vue",
contents,
declaration: false,
});

return output;
};
}

export function defineDefaultBlockLoader(
options: DefaultBlockLoaderOptions,
): VueBlockLoader {
return async (block, { loadFile, rawInput, addOutput }) => {
if (options.type !== block.type) {
return;
}

const [, lang = options.outputLang] =
attributes.match(/lang="([a-z]*)"/) || [];
const extension = "." + lang;
const lang =
typeof block.attrs.lang === "string"
? block.attrs.lang
: options.outputLang;
const extension = `.${lang}`;

const files =
(await loadFile({
getContents: () => blockContents,
path: `${input.path}${extension}`,
srcPath: `${input.srcPath}${extension}`,
getContents: () => block.content,
path: `${rawInput.path}${extension}`,
srcPath: `${rawInput.srcPath}${extension}`,
extension,
})) || [];

const blockOutputFile = files.find(
(f) =>
f.extension === `.${options.outputLang}` ||
options.validExtensions?.includes(f.extension),
options.validExtensions?.includes(f.extension as string),
);
if (!blockOutputFile) {
if (!blockOutputFile?.contents) {
return;
}
addOutput(...files.filter((f) => f !== blockOutputFile));

const newAttributes = attributes.replace(
new RegExp(`\\s?lang="${lang}"`),
"",
);
return [
{
path: input.path,
contents: contents.replace(
block,
`<${
options.type
}${newAttributes}>\n${blockOutputFile.contents?.trim()}\n</${
options.type
}>`,
),
},
...files.filter((f) => f !== blockOutputFile),
];
return {
type: block.type,
attrs: toOmit(block.attrs, "lang"),
content: blockOutputFile.contents,
};
};
}

const styleLoader = vueBlockLoader({
const styleLoader = defineDefaultBlockLoader({
outputLang: "css",
type: "style",
});

const scriptLoader = vueBlockLoader({
const scriptLoader = defineDefaultBlockLoader({
outputLang: "js",
type: "script",
exclude: [/\bsetup\b/],
validExtensions: [".js", ".mjs"],
});

export const vueLoader = defineVueLoader({
blockLoaders: {
style: styleLoader,
script: scriptLoader,
},
});

function createFs(pwd?: string) {
const realpath = (...paths: string[]) =>
pwd ? resolve(dirname(pwd), ...paths) : resolve(...paths);

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

View check run for this annotation

Codecov / codecov/patch

src/loaders/vue.ts#L196

Added line #L196 was not covered by tests
const fileExists = (file: string) => {
try {
if (!pwd) {
return false;
}
const path = realpath(file);

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

View check run for this annotation

Codecov / codecov/patch

src/loaders/vue.ts#L198-L202

Added lines #L198 - L202 were not covered by tests

fs.accessSync(path);
return fs.lstatSync(path).isFile();
} catch {
return false;
}
};

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

View check run for this annotation

Codecov / codecov/patch

src/loaders/vue.ts#L204-L209

Added lines #L204 - L209 were not covered by tests
const readFile = (file: string) => {
return fs.readFileSync(realpath(file), "utf8");
};

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

View check run for this annotation

Codecov / codecov/patch

src/loaders/vue.ts#L211-L212

Added lines #L211 - L212 were not covered by tests

return { realpath, fileExists, readFile };
}
function cleanupBreakLine(str: string): string {
return str.replaceAll(/(\n\n)\n+/g, "\n\n").replace(/^\s*\n|\n\s*$/g, "");
}
function toOmit<R extends Record<keyof object, unknown>, K extends keyof R>(
record: R,
toRemove: K,
): Omit<R, K> {
return Object.fromEntries(
Object.entries(record).filter(([key]) => key !== toRemove),
) as Omit<R, K>;
}
Loading

0 comments on commit db97651

Please sign in to comment.