feat: imported from @tanstack/router base vite-plugin logic

This commit is contained in:
Valerio Ageno
2024-04-28 10:46:45 +02:00
parent c0d4425414
commit 317169c5e4
19 changed files with 678 additions and 13 deletions
+1 -1
View File
@@ -59,7 +59,7 @@
],
"@typescript-eslint/no-empty-function": "error",
"@typescript-eslint/no-empty-interface": "error",
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-non-null-assertion": "error",
"@typescript-eslint/no-unnecessary-condition": "error",
"@typescript-eslint/no-unnecessary-type-assertion": "error",
+2
View File
@@ -0,0 +1,2 @@
prefer-workspace-packages=true
+1
View File
@@ -0,0 +1 @@
v20.10.0
+1
View File
@@ -9,6 +9,7 @@
"format": "turbo format"
},
"keywords": [],
"type": "module",
"author": "",
"license": "MIT",
"devDependencies": {
+18 -2
View File
@@ -8,6 +8,7 @@
"build": "vite build",
"lint": "eslint --ext .ts,.tsx ./src -c ../../.eslintrc",
"format": "prettier --ignore-unknown '**/*'",
"test": "vitest --typecheck --watch=false",
"types": "tsc --noEmit"
},
"exports": {
@@ -32,10 +33,25 @@
"type": "module",
"author": "Valerio Ageno",
"license": "MIT",
"devDependencies": {
"dependencies": {
"@babel/core": "^7.24.4",
"@babel/generator": "^7.23.6",
"@babel/plugin-syntax-jsx": "^7.24.1",
"@babel/plugin-syntax-typescript": "^7.24.1",
"@babel/plugin-transform-react-jsx": "^7.23.4",
"@babel/plugin-transform-typescript": "^7.24.1",
"@babel/template": "^7.24.0",
"@babel/traverse": "^7.24.1",
"@babel/types": "^7.24.0",
"@types/babel__core": "^7.20.5",
"@vitejs/plugin-react": "^4.2.1",
"rollup-plugin-preserve-directives": "^0.4.0",
"vite": "^5.2.10",
"vite-plugin-dts": "^3.9.0",
"vite-plugin-externalize-deps": "^0.8.0"
"vite-plugin-externalize-deps": "^0.8.0",
"vitest": "^1.5.2"
},
"devDependencies": {
"@types/babel-traverse": "^6.25.10"
}
}
+277 -4
View File
@@ -1,11 +1,284 @@
export const makeCompile = (): void => {
console.log('compiling [compile fn]')
import * as babel from '@babel/core'
import * as t from '@babel/types'
import { eliminateUnreferencedIdentifiers } from './eliminateUnreferencedIdentifiers'
import { SPLIT_PREFIX } from './constants'
import type { BabelFileResult } from '@babel/core'
type SplitModulesById = Record<
string,
{ id: string; node: t.FunctionExpression }
>
interface State {
filename: string
opts: {
minify: boolean
root: string
}
imported: Record<string, boolean>
refs: Set<any>
serverIndex: number
splitIndex: number
splitModulesById: SplitModulesById
}
interface MakeCompileFnArgs {
root: string
}
interface MakeCompileFnReturnArgs {
code: string
filename: string
getBabelConfig: () => { plugins: any[] }
}
interface CompileOutput {
code: string
map: BabelFileResult['map']
}
type SplitNodeType = (typeof splitNodeTypes)[number]
export const makeCompile = ({
root,
}: MakeCompileFnArgs): ((args: MakeCompileFnReturnArgs) => CompileOutput) => {
return ({ code, getBabelConfig, filename }) => {
const res = babel.transformSync(code, {
plugins: [
['@babel/plugin-syntax-jsx', {}],
['@babel/plugin-syntax-typescript', { isTSX: true }],
...getBabelConfig().plugins,
],
root,
filename,
sourceMaps: true,
})
if (res?.code) {
return {
code: res.code,
map: res.map,
}
}
return {
code,
map: null,
}
}
}
interface SplitFileFnArgs {
code: string
compile: (args: MakeCompileFnReturnArgs) => CompileOutput
filename: string
}
export const splitFile = async ({ code }: SplitFileFnArgs): Promise<void> => {
console.log('Split file [splitFile fn]', code)
// NOTE: We don't need the loader
const splitNodeTypes = ['component', 'loader'] as const
// Reusable function to get literal value or resolve variable to literal
function resolveIdentifier(path: any, node: any) {
if (t.isIdentifier(node)) {
const binding = path.scope.getBinding(node.name)
if (binding) {
const declarator = binding.path.node
if (t.isObjectExpression(declarator.init)) {
return declarator.init
} else if (t.isFunctionDeclaration(declarator.init)) {
return declarator.init
}
}
return undefined
}
return node
}
export const splitFile = async ({
code,
compile,
filename,
}: SplitFileFnArgs): Promise<void> => {
console.log('Split file [splitFile fn]', code)
return compile({
code,
filename,
getBabelConfig: () => ({
plugins: [
[
{
visitor: {
Program: {
enter(
programPath: babel.NodePath<t.Program>,
state: State,
): void {
const splitNodesByType: Record<
SplitNodeType,
t.Node | undefined
> = {
component: undefined,
loader: undefined,
}
// Find the node
// NOTE: I'd use a more NextJS approach.
// All the "default exported" fns are the page.
programPath.traverse(
{
CallExpression: (path) => {
if (path.node.callee.type === 'Identifier') {
// TODO: Update this to support just "default exported" fns
if (path.node.callee.name === 'createFileRoute') {
if (
path.parentPath.node.type === 'CallExpression'
) {
const options = resolveIdentifier(
path,
path.parentPath.node.arguments[0],
)
if (t.isObjectExpression(options)) {
options.properties.forEach((prop) => {
if (t.isObjectProperty(prop)) {
splitNodeTypes.forEach((type) => {
if (t.isIdentifier(prop.key)) {
if (prop.key.name === 'type') {
splitNodesByType[type] = prop.value
}
}
})
}
})
// Remove all of the options
options.properties = []
}
}
}
}
},
},
state,
)
// TODO: We don't need to iterate since we don't support the loader
splitNodeTypes.forEach((splitType) => {
let splitNode = splitNodesByType[splitType]
if (!splitNode) {
return
}
while (t.isIdentifier(splitNode)) {
const binding = programPath.scope.getBinding(
splitNode.name,
)
splitNode = binding?.path.node
}
// Add the node to the program
if (splitNode) {
if (t.isFunctionDeclaration(splitNode)) {
programPath.pushContainer(
'body',
t.variableDeclaration('const', [
t.variableDeclarator(
t.identifier(splitType),
t.functionExpression(
splitNode.id || null,
splitNode.params,
splitNode.body,
splitNode.generator,
splitNode.async,
),
),
]),
)
} else if (
t.isFunctionExpression(splitNode) ||
t.isArrowFunctionExpression(splitNode)
) {
programPath.pushContainer(
'body',
t.variableDeclaration('const', [
t.variableDeclarator(
t.identifier(splitType),
splitType as any,
),
]),
)
} else if (t.isImportSpecifier(splitNode)) {
programPath.pushContainer(
'body',
t.variableDeclaration('const', [
t.variableDeclarator(
t.identifier(splitType),
splitNode.local,
),
]),
)
} else {
console.log(splitNode)
throw new Error(
`Unexpected splitNode type ${splitNode.type}`,
)
}
}
// If the splitNode exists at the top of the program
// then we need to remove that copy
programPath.node.body = programPath.node.body.filter(
(node) => {
return node !== splitNode
},
)
// Export the node
programPath.pushContainer('body', [
t.exportNamedDeclaration(null, [
t.exportSpecifier(
t.identifier(splitType),
t.identifier(splitType),
),
]),
])
})
// Convert exports to imports from the original file
programPath.traverse({
ExportNamedDeclaration(path) {
// e.g. export const x = 1 or export { x }
// becomes
// import { x } from '${opts.id}'
if (path.node.declaration) {
if (t.isVariableDeclaration(path.node.declaration)) {
path.replaceWith(
t.importDeclaration(
path.node.declaration.declarations.map((decl) =>
t.importSpecifier(
t.identifier((decl.id as any).name),
t.identifier((decl.id as any).name),
),
),
t.stringLiteral(
filename.split(`?${SPLIT_PREFIX}`)[0] as string,
),
),
)
}
}
},
})
eliminateUnreferencedIdentifiers(programPath)
},
},
},
},
],
],
}),
})
}
+1
View File
@@ -0,0 +1 @@
export const SPLIT_PREFIX = 'sp-'
@@ -0,0 +1,298 @@
// Copied from https://github.com/pcattori/vite-env-only/blob/main/src/dce.ts
// Adapted with some minor changes for the purpose of this project
import * as t from '@babel/types'
import type { types as BabelTypes } from '@babel/core'
import type { NodePath } from '@babel/traverse'
type IdentifierPath = NodePath<BabelTypes.Identifier>
// export function findReferencedIdentifiers(
// programPath: NodePath<BabelTypes.Program>,
// ): Set<IdentifierPath> {
// const refs = new Set<IdentifierPath>()
// function markFunction(
// path: NodePath<
// | BabelTypes.FunctionDeclaration
// | BabelTypes.FunctionExpression
// | BabelTypes.ArrowFunctionExpression
// >,
// ) {
// const ident = getIdentifier(path)
// if (ident?.node && isIdentifierReferenced(ident)) {
// refs.add(ident)
// }
// }
// function markImport(
// path: NodePath<
// | BabelTypes.ImportSpecifier
// | BabelTypes.ImportDefaultSpecifier
// | BabelTypes.ImportNamespaceSpecifier
// >,
// ) {
// const local = path.get('local')
// if (isIdentifierReferenced(local)) {
// refs.add(local)
// }
// }
// programPath.traverse({
// VariableDeclarator(path) {
// if (path.node.id.type === 'Identifier') {
// const local = path.get('id') as NodePath<BabelTypes.Identifier>
// if (isIdentifierReferenced(local)) {
// refs.add(local)
// }
// } else if (path.node.id.type === 'ObjectPattern') {
// const pattern = path.get('id') as NodePath<BabelTypes.ObjectPattern>
// const properties = pattern.get('properties')
// properties.forEach((p) => {
// const local = p.get(
// p.node.type === 'ObjectProperty'
// ? 'value'
// : p.node.type === 'RestElement'
// ? 'argument'
// : (function () {
// throw new Error('invariant')
// })(),
// ) as NodePath<BabelTypes.Identifier>
// if (isIdentifierReferenced(local)) {
// refs.add(local)
// }
// })
// } else if (path.node.id.type === 'ArrayPattern') {
// const pattern = path.get('id') as NodePath<BabelTypes.ArrayPattern>
// const elements = pattern.get('elements')
// elements.forEach((e) => {
// let local: NodePath<BabelTypes.Identifier>
// if (e.node?.type === 'Identifier') {
// local = e as NodePath<BabelTypes.Identifier>
// } else if (e.node?.type === 'RestElement') {
// local = e.get('argument') as NodePath<BabelTypes.Identifier>
// } else {
// return
// }
// if (isIdentifierReferenced(local)) {
// refs.add(local)
// }
// })
// }
// },
// FunctionDeclaration: markFunction,
// FunctionExpression: markFunction,
// ArrowFunctionExpression: markFunction,
// ImportSpecifier: markImport,
// ImportDefaultSpecifier: markImport,
// ImportNamespaceSpecifier: markImport,
// })
// return refs
// }
/**
* @param refs - If provided, only these identifiers will be considered for removal.
*/
export const eliminateUnreferencedIdentifiers = (
programPath: NodePath<BabelTypes.Program>,
refs?: Set<IdentifierPath>,
): void => {
let referencesRemovedInThisPass: number
const shouldBeRemoved = (ident: IdentifierPath): boolean => {
if (isIdentifierReferenced(ident)) return false
if (!refs) return true
return refs.has(ident)
}
const sweepFunction = (
path: NodePath<
| BabelTypes.FunctionDeclaration
| BabelTypes.FunctionExpression
| BabelTypes.ArrowFunctionExpression
>,
): void => {
const identifier = getIdentifier(path)
if (identifier?.node && shouldBeRemoved(identifier)) {
++referencesRemovedInThisPass
if (
t.isAssignmentExpression(path.parentPath.node) ||
t.isVariableDeclarator(path.parentPath.node)
) {
path.parentPath.remove()
} else {
path.remove()
}
}
}
const sweepImport = (
path: NodePath<
| BabelTypes.ImportSpecifier
| BabelTypes.ImportDefaultSpecifier
| BabelTypes.ImportNamespaceSpecifier
>,
): void => {
const local = path.get('local')
if (shouldBeRemoved(local)) {
++referencesRemovedInThisPass
path.remove()
if (
(path.parent as BabelTypes.ImportDeclaration).specifiers.length === 0
) {
path.parentPath.remove()
}
}
}
// Traverse again to remove unused references. This happens at least once,
// then repeats until no more references are removed.
do {
referencesRemovedInThisPass = 0
programPath.scope.crawl()
programPath.traverse({
VariableDeclarator(path) {
if (path.node.id.type === 'Identifier') {
const local = path.get('id') as NodePath<BabelTypes.Identifier>
if (shouldBeRemoved(local)) {
++referencesRemovedInThisPass
path.remove()
}
} else if (path.node.id.type === 'ObjectPattern') {
const pattern = path.get('id') as NodePath<BabelTypes.ObjectPattern>
const beforeCount = referencesRemovedInThisPass
const properties = pattern.get('properties')
properties.forEach((property) => {
const local = property.get(
property.node.type === 'ObjectProperty'
? 'value'
: // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
property.node.type === 'RestElement'
? 'argument'
: (function () {
throw new Error('invariant')
})(),
) as NodePath<BabelTypes.Identifier>
if (shouldBeRemoved(local)) {
++referencesRemovedInThisPass
property.remove()
}
})
if (
beforeCount !== referencesRemovedInThisPass &&
pattern.get('properties').length < 1
) {
path.remove()
}
} else if (path.node.id.type === 'ArrayPattern') {
const pattern = path.get('id') as NodePath<BabelTypes.ArrayPattern>
let hasRemoved = false as boolean
pattern.get('elements').forEach((element, index) => {
// if (!element) return // Skip holes in the pattern
let identifierPath: NodePath<BabelTypes.Identifier>
if (t.isIdentifier(element.node)) {
identifierPath = element as NodePath<BabelTypes.Identifier>
} else if (t.isRestElement(element.node)) {
identifierPath = element.get(
'argument',
) as NodePath<BabelTypes.Identifier>
} else {
// For now, ignore other types like AssignmentPattern
return
}
if (shouldBeRemoved(identifierPath)) {
hasRemoved = true
pattern.node.elements[index] = null // Remove the element by setting it to null
}
})
// If any elements were removed and no elements are left, remove the entire declaration
if (
hasRemoved &&
pattern.node.elements.every((element) => element === null)
) {
path.remove()
++referencesRemovedInThisPass
}
}
},
FunctionDeclaration: sweepFunction,
FunctionExpression: sweepFunction,
ArrowFunctionExpression: sweepFunction,
ImportSpecifier: sweepImport,
ImportDefaultSpecifier: sweepImport,
ImportNamespaceSpecifier: sweepImport,
})
} while (referencesRemovedInThisPass)
}
function getIdentifier(
path: NodePath<
| BabelTypes.FunctionDeclaration
| BabelTypes.FunctionExpression
| BabelTypes.ArrowFunctionExpression
>,
): NodePath<BabelTypes.Identifier> | null {
const parentPath = path.parentPath
if (parentPath.type === 'VariableDeclarator') {
const variablePath = parentPath as NodePath<BabelTypes.VariableDeclarator>
const name = variablePath.get('id')
return name.node.type === 'Identifier'
? (name as NodePath<BabelTypes.Identifier>)
: null
}
if (parentPath.type === 'AssignmentExpression') {
const variablePath = parentPath as NodePath<BabelTypes.AssignmentExpression>
const name = variablePath.get('left')
return name.node.type === 'Identifier'
? (name as NodePath<BabelTypes.Identifier>)
: null
}
if (path.node.type === 'ArrowFunctionExpression') {
return null
}
if (path.node.type === 'FunctionExpression') {
return null
}
return path.node.id && path.node.id.type === 'Identifier'
? (path.get('id') as NodePath<BabelTypes.Identifier>)
: null
}
function isIdentifierReferenced(
ident: NodePath<BabelTypes.Identifier>,
): boolean {
const binding = ident.scope.getBinding(ident.node.name)
if (binding?.referenced) {
// Functions can reference themselves, so we need to check if there's a
// binding outside the function scope or not.
if (binding.path.type === 'FunctionDeclaration') {
return !binding.constantViolations
.concat(binding.referencePaths)
// Check that every reference is contained within the function:
.every((ref) => ref.findParent((parent) => parent === binding.path))
}
return true
}
return false
}
+12 -3
View File
@@ -3,21 +3,23 @@ import { fileURLToPath, pathToFileURL } from 'url'
import { normalize } from 'path'
// eslint-disable-next-line sort-imports
import { makeCompile, splitFile } from './compiler'
import { SPLIT_PREFIX } from './constants'
import type { Plugin } from 'vite'
const SPLIT_PREFIX = 'sp-'
const ROUTES_DIRECTORY_PATH = 'src/routes'
const DEBUG = true
let lock = false
export function RouterGenerator(): Plugin {
const _generate = async (): Promise<void> => {
const generate = async (): Promise<void> => {
if (lock) return
lock = true
try {
// TODO: generator function
// This generator function is from the router-generator package
console.log('Generating [generate fn]')
} catch (err) {
console.log(err)
@@ -32,11 +34,15 @@ export function RouterGenerator(): Plugin {
if (filePath.startsWith(ROUTES_DIRECTORY_PATH)) {
// TODO: generator function
console.log('Generating [handleFile fn]')
await generate()
}
}
return {
name: 'vite-plugin-fs-router-generator',
configResolved: async (): Promise<void> => {
await generate()
},
watchChange: async (
file: string,
context: { event: string },
@@ -49,6 +55,8 @@ export function RouterGenerator(): Plugin {
}
export function RouterCodeSplitter(): Plugin {
const ROOT: string = process.cwd()
return {
name: 'vite-plugin-fs-router-code-splitter',
enforce: 'pre',
@@ -63,7 +71,8 @@ export function RouterCodeSplitter(): Plugin {
url.searchParams.delete('v')
id = fileURLToPath(url).replace(/\\/g, '/')
const compile = makeCompile()
const compile = makeCompile({ root: ROOT })
if (DEBUG) console.info('Route: ', id)
if (id.includes(SPLIT_PREFIX)) {
@@ -0,0 +1,34 @@
import { readFile, readdir } from 'fs/promises'
import path from 'path'
import { expect, test } from 'vitest'
import { makeCompile, splitFile } from '../src/compiler'
import { SPLIT_PREFIX } from '../src/constants'
async function splitTestFile(opts: { file: string }): void {
const code = (
await readFile(path.resolve(__dirname, `./test-files/${opts.file}`))
).toString()
const filename = opts.file.replace(__dirname, '')
const result = await splitFile({
code,
compile: makeCompile({
root: './test-files',
}),
filename: `${filename}?${SPLIT_PREFIX}`,
})
await expect(result.code).toMatchFileSnapshot(
`./snapshots/${filename.replace('.tsx', '')}?split.tsx`,
)
}
test('it compiles and splits', async (): Promise<void> => {
// get the list of files from the /test-files directory
const files = await readdir(path.resolve(__dirname, './test-files'))
for (const file of files) {
console.log('Testing:', file)
await splitTestFile({ file })
}
})
@@ -0,0 +1,3 @@
export const ImportedRoute(): JSX.Element => {
return <h1>Imported route</h1>
}
@@ -0,0 +1,3 @@
export default function Route(): JSX.Element {
return <h1>Test route</h1>;
}
@@ -0,0 +1,4 @@
function PostsRoute() {
return <h1>Post route</h1>;
}
export default PostsRoute;
@@ -0,0 +1,2 @@
import { ImportedRoute } from '../shared/imported';
export default ImportedRoute;
@@ -0,0 +1,5 @@
import * as React from 'react'
export default function Route(): JSX.Element {
return <h1>Test route</h1>
}
@@ -0,0 +1,7 @@
import * as React from 'react'
function PostsRoute() {
return <h1>Post route</h1>
}
export default PostsRoute
@@ -0,0 +1,4 @@
import * as React from 'react'
import { ImportedRoute } from '../shared/imported'
export default ImportedRoute
+4 -3
View File
@@ -1,9 +1,9 @@
import { defineConfig } from 'vite'
import { preserveDirectives } from 'rollup-plugin-preserve-directives'
import { externalizeDeps } from 'vite-plugin-externalize-deps'
import dts from 'vite-plugin-dts'
import { externalizeDeps } from 'vite-plugin-externalize-deps'
import { preserveDirectives } from 'rollup-plugin-preserve-directives'
const config = ({ entryRoot, include, exclude }) =>
const config = ({ entryRoot, include, exclude }): any =>
defineConfig({
plugins: [
externalizeDeps(),
@@ -72,4 +72,5 @@ const config = ({ entryRoot, include, exclude }) =>
export default config({
entryRoot: './src/index.ts',
include: './src',
exclude: ['./src/tests/'],
})
+1
View File
@@ -24,4 +24,5 @@
"target": "ES2020",
},
"include": [".eslintrc.cjs", "prettier.config.js"],
"exclude": ["node_modules"]
}