Skip to content
Merged
6 changes: 6 additions & 0 deletions .changeset/seven-hornets-jump.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@flint.fyi/vue-language": minor
"@flint.fyi/vue": minor
---

Introduce Vue language.
1 change: 1 addition & 0 deletions cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"bday",
"BDFL",
"bradzacher",
"codegen",
"codemod",
"contentinfo",
"deoptimizations",
Expand Down
3 changes: 3 additions & 0 deletions knip.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@
"sharp",
"zod"
]
},
"packages/vue": {
"ignoreDependencies": ["vue"]
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's required for tests (virtual code for .vue files contains vue package imports)

}
}
}
2 changes: 1 addition & 1 deletion packages/typescript-language/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"name": "JoshuaKGoldberg",
"email": "npm@joshuakgoldberg.com"
},
"sideEffects": false,
"sideEffects": true,
"type": "module",
"exports": {
".": "./src/index.ts"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ function displayFilename(name: string) {
if (name.startsWith("./")) {
return name.slice(2);
}
// TODO: use LinterHost.getCurrentDirectory()
return name.slice(process.cwd().length + 1);
}

Expand Down
2 changes: 1 addition & 1 deletion packages/volar-language/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"name": "JoshuaKGoldberg",
"email": "npm@joshuakgoldberg.com"
},
"sideEffects": false,
"sideEffects": true,
"type": "module",
"exports": {
".": "./src/index.ts"
Expand Down
9 changes: 9 additions & 0 deletions packages/vue-language/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<h1 align="center"><code>@flint.fyi/vue-language</code></h1>

<p align="center">
[Experimental] Vue.js language for Flint.
❤️‍🔥
</p>

This is an internal package for Flint.
Documentation will eventually be added.
52 changes: 52 additions & 0 deletions packages/vue-language/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
{
"name": "@flint.fyi/vue-language",
"version": "0.0.1",
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It'd be cool if there was some tooling to warn us against starting numbering at random #s like we accidentally did for a few packages.

"description": "[Experimental] Vue.js language for Flint.",
"homepage": "https://flint.fyi",
"repository": {
"type": "git",
"url": "git+https://github.com/flint-fyi/flint.git",
"directory": "packages/vue-language"
},
"license": "MIT",
"author": {
"name": "JoshuaKGoldberg",
"email": "npm@joshuakgoldberg.com"
},
"sideEffects": true,
"type": "module",
"exports": {
".": "./src/index.ts"
},
"files": [
"lib/",
"!lib/**/*.map"
],
"scripts": {
"test": "vitest --project vue-language"
},
"dependencies": {
"@flint.fyi/ts-patch": "workspace:^",
"@flint.fyi/typescript-language": "workspace:^",
"@flint.fyi/utils": "workspace:^",
"@flint.fyi/volar-language": "workspace:^",
"@volar/language-core": "2.4.28",
"@vue/compiler-dom": "^3.5.0",
"@vue/language-core": "3.2.1",
"typescript": "^5.9.0 || ^6.0.0"
},
"devDependencies": {
"@flint.fyi/core": "workspace:^",
"tsdown": "catalog:dev",
"vitest": "catalog:dev"
},
"engines": {
"node": ">=24.0.0"
},
"publishConfig": {
"access": "public",
"exports": {
".": "./lib/index.js"
}
}
}
56 changes: 56 additions & 0 deletions packages/vue-language/src/extractTemplateDirectives.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type { ExtractedDirective } from "@flint.fyi/typescript-language";
import { nullThrows } from "@flint.fyi/utils";
import {
NodeTypes,
type RootNode,
type TemplateChildNode,
} from "@vue/compiler-dom";

export function extractTemplateDirectives(ast: RootNode) {
const directives: ExtractedDirective[] = [];

function visitTemplate(elem: TemplateChildNode) {
if (elem.type === NodeTypes.ELEMENT) {
for (const child of elem.children) {
visitTemplate(child);
}
return;
}
if (elem.type !== NodeTypes.COMMENT) {
return;
}
const match = /\s*flint-(\S+)(?:\s+(.+))?/.exec(elem.content);
if (match == null) {
return;
}
const [, type, selection] = match;
directives.push({
range: {
begin: {
column: elem.loc.start.column - 1,
line: elem.loc.start.line - 1,
raw: elem.loc.start.offset,
},
end: {
column: elem.loc.end.column - 1,
line: elem.loc.end.line - 1,
raw: elem.loc.end.offset,
},
},
selection: nullThrows(
selection,
"Expected RegExp to provide second capturing group",
),
type: nullThrows(
type,
"Expected RegExp to provide first capturing group",
),
});
}

for (const child of ast.children) {
visitTemplate(child);
}

return directives;
}
1 change: 1 addition & 0 deletions packages/vue-language/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { vueLanguage } from "./language.ts";
128 changes: 128 additions & 0 deletions packages/vue-language/src/language.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { setTSExtraSupportedExtensions } from "@flint.fyi/ts-patch";
import { assert, nullThrows } from "@flint.fyi/utils";
import { createVolarBasedLanguage } from "@flint.fyi/volar-language";
import type { Mapper as VolarMapper } from "@volar/language-core";
import { NodeTypes, type RootNode, parse as vueParse } from "@vue/compiler-dom";
import {
createVueLanguagePlugin,
createParsedCommandLine as createVueParsedCommandLine,
createParsedCommandLineByJson as createVueParsedCommandLineByJson,
tsCodegen,
VueVirtualCode,
} from "@vue/language-core";

import { extractTemplateDirectives } from "./extractTemplateDirectives.ts";
import { vueParsingErrorsToLanguageDiagnostics } from "./vueParsingErrorsToLanguageDiagnostics.ts";

setTSExtraSupportedExtensions([".vue"]);

export interface VueServices {
vue: {
codegen: VueCodegen;
map: VolarMapper;
sfc: RootNode;
virtualCode: VueVirtualCode;
};
}

type VueCodegen =
typeof tsCodegen extends WeakMap<WeakKey, infer V> ? V : never;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Aside] I cannot wait for the blog post explaining this architecture 😂 it's going to cause a lot of conversations around tooling folks.


export const vueLanguage = createVolarBasedLanguage<VueServices>(
(ts, options) => {
const { configFilePath } = options.options;
const host = options.host
? {
...options.host,
useCaseSensitiveFileNames: options.host.useCaseSensitiveFileNames(),
}
: ts.sys;
const vueCompilerOptions = (
typeof configFilePath === "string"
? createVueParsedCommandLine(
ts,
host,
configFilePath.replaceAll("\\", "/"),
)
: createVueParsedCommandLineByJson(
ts,
host,
host.getCurrentDirectory(),
{},
)
).vueOptions;

return {
createFile({
data,
serviceScript,
sourceFile,
sourceScript,
volarLanguage,
}) {
const sourceText = sourceScript.snapshot.getText(
0,
sourceScript.snapshot.getLength(),
);
const virtualCode = sourceScript.generated.root;
assert(
virtualCode instanceof VueVirtualCode,
"Expected sourceScript.generated.root to be VueServiceCode",
);

const codegen = nullThrows(
tsCodegen.get(virtualCode.sfc),
`tsCodegen for ${data.filePathAbsolute} is undefined`,
);

const map = volarLanguage.maps.get(serviceScript.code, sourceScript);

const sfcAst = vueParse(sourceText, {
comments: true,
expressionPlugins: ["typescript"],
onError: () => {
// We ignore errors because virtual code already provides them,
// and it also provides them with sourceText-based locations,
// so we don't have to remap them. Oh, and it also contains errors from
// other blocks rather than only <template> as well.
// If we don't provide this callback, @vue/compiler-core will throw.
},
parseMode: "html",
});

return {
// TODO: extract directives from other blocks too
directives: extractTemplateDirectives(sfcAst),
extraContext: {
vue: {
codegen,
map,
sfc: sfcAst,
virtualCode,
},
},
firstStatementPosition:
sfcAst.children.find((c) => c.type !== NodeTypes.COMMENT)?.loc.start
.offset ?? sourceText.length,
getDiagnostics() {
return vueParsingErrorsToLanguageDiagnostics(
sourceFile.fileName.startsWith("./")
? sourceFile.fileName.slice(2)
: // TODO: use LinterHost.getCurrentDirectory()
sourceFile.fileName.slice(process.cwd().length + 1),
virtualCode.vueSfc?.errors ?? [],
);
},
};
},
languagePlugins: [
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is just so awesome 🚀

createVueLanguagePlugin<string>(
ts,
options.options,
vueCompilerOptions,
(id) => id,
),
],
};
},
);
23 changes: 23 additions & 0 deletions packages/vue-language/src/vueParsingErrorsToLanguageDiagnostics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { LanguageDiagnostics } from "@flint.fyi/core";
import type { CompilerError } from "@vue/compiler-dom";

export function vueParsingErrorsToLanguageDiagnostics(
fileName: string,
errors: (CompilerError | SyntaxError)[],
): LanguageDiagnostics {
return errors.map((error) => {
let code = "VUE";
let loc = "";
if ("code" in error) {
code += error.code.toString();
loc =
error.loc != null
? `:${error.loc.start.line}:${error.loc.start.column}`
: "";
}
return {
code,
text: `${fileName}${loc} - ${code}: ${error.name} - ${error.message}`,
};
});
}
8 changes: 8 additions & 0 deletions packages/vue-language/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"include": [],
"references": [
{ "path": "./tsconfig.src.json" },
{ "path": "./tsconfig.test.json" }
]
}
18 changes: 18 additions & 0 deletions packages/vue-language/tsconfig.src.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuild/info.src.json",
"rootDir": "src/",
"outDir": "lib/",
"types": ["node"]
},
"extends": "../../tsconfig.base.json",
"include": ["src"],
"exclude": ["src/**/*.test.ts"],
"references": [
{ "path": "../core" },
{ "path": "../ts-patch" },
{ "path": "../typescript-language" },
{ "path": "../utils" },
{ "path": "../volar-language" }
]
}
12 changes: 12 additions & 0 deletions packages/vue-language/tsconfig.test.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuild/info.test.json",
"rootDir": "src/",
"outDir": "node_modules/.cache/tsbuild/test",
"types": ["node"],
"erasableSyntaxOnly": false
},
"extends": "../../tsconfig.base.json",
"include": ["src/**/*.test.ts"],
"references": [{ "path": "./tsconfig.src.json" }]
}
19 changes: 19 additions & 0 deletions packages/vue-language/tsdown.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { defineConfig } from "tsdown";

export default defineConfig({
attw: {
enabled: "ci-only",
profile: "esm-only",
},
clean: ["./node_modules/.cache/tsbuild/"],
dts: { build: true, incremental: true },
entry: ["src/index.ts"],
exports: {
devExports: true,
packageJson: false,
},
failOnWarn: true,
fixedExtension: false,
outDir: "lib",
unbundle: true,
});
9 changes: 9 additions & 0 deletions packages/vue/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<h1 align="center"><code>@flint.fyi/vue</code></h1>

<p align="center">
[Experimental] Vue.js language plugin for Flint.
❤️‍🔥
</p>

This is an internal package for Flint.
Documentation will eventually be added.
Loading