mirror of
https://github.com/tuono-labs/tuono
synced 2026-07-25 12:52:47 -07:00
Crate standalone fs router package (#11)
* refactor: moved fs-router to standalone package * chore: remove useless split source function * feat: add simple test cases * fix: README formatting * fix: remove useless RouteSubNode * feat: update version to v0.4.4
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "tuono"
|
||||
version = "0.4.3"
|
||||
version = "0.4.4"
|
||||
edition = "2021"
|
||||
authors = ["V. Ageno <valerioageno@yahoo.it>"]
|
||||
description = "The react/rust fullstack framework"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "tuono_lib"
|
||||
version = "0.4.3"
|
||||
version = "0.4.4"
|
||||
edition = "2021"
|
||||
authors = ["V. Ageno <valerioageno@yahoo.it>"]
|
||||
description = "The react/rust fullstack framework"
|
||||
@@ -31,7 +31,7 @@ regex = "1.10.5"
|
||||
either = "1.13.0"
|
||||
tower-http = {version = "0.5.2", features = ["fs"]}
|
||||
|
||||
tuono_lib_macros = {path = "../tuono_lib_macros", version = "0.4.3"}
|
||||
tuono_lib_macros = {path = "../tuono_lib_macros", version = "0.4.4"}
|
||||
# Match the same version used by axum
|
||||
tokio-tungstenite = "0.21.0"
|
||||
futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "tuono_lib_macros"
|
||||
version = "0.4.3"
|
||||
version = "0.4.4"
|
||||
edition = "2021"
|
||||
description = "The react/rust fullstack framework"
|
||||
keywords = [ "react", "typescript", "fullstack", "web", "ssr"]
|
||||
|
||||
+2
-1
@@ -13,7 +13,8 @@
|
||||
},
|
||||
"workspaces": [
|
||||
"tuono",
|
||||
"tuono-lazy-fn-vite-plugin"
|
||||
"tuono-lazy-fn-vite-plugin",
|
||||
"tuono-fs-router-vite-plugin"
|
||||
],
|
||||
"author": "Valerio Ageno",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# tuono-fs-router-vite-plugin
|
||||
|
||||
This is a vite plugin for [tuono](https://github.com/Valerioageno/tuono).
|
||||
|
||||
This package specifically handles the file system based routing.
|
||||
|
||||
Check [tuono](https://github.com/Valerioageno/tuono) for more.
|
||||
|
||||
## Credits
|
||||
|
||||
This plugin is strongly inspired by the [@tanstack/router](https://tanstack.com/router/latest)
|
||||
route generator plugin.
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "tuono-fs-router-vite-plugin",
|
||||
"version": "0.4.4",
|
||||
"description": "Plugin for the tuono's file system router. Tuono is the react/rust fullstack framework",
|
||||
"scripts": {
|
||||
"dev": "vite build --watch",
|
||||
"build": "vite build",
|
||||
"lint": "eslint --ext .ts,.tsx ./src -c ../../.eslintrc",
|
||||
"format": "prettier -u --write --ignore-unknown '**/*'",
|
||||
"format:check": "prettier --check --ignore-unknown '**/*'",
|
||||
"types": "tsc --noEmit",
|
||||
"test:watch": "vitest",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "Valerio Ageno",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"types": "dist/esm/index.d.ts",
|
||||
"main": "dist/cjs/index.cjs",
|
||||
"module": "dist/esm/index.js",
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"README.md"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/esm/index.d.ts",
|
||||
"default": "./dist/esm/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/cjs/index.d.cts",
|
||||
"default": "./dist/cjs/index.cjs"
|
||||
}
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.24.4",
|
||||
"@babel/types": "^7.24.0",
|
||||
"prettier": "^3.2.4",
|
||||
"vite": "^5.2.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tanstack/config": "^0.7.11",
|
||||
"@types/babel__core": "^7.20.5",
|
||||
"vitest": "^1.5.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export const ROUTES_FOLDER = './src/routes/'
|
||||
export const ROOT_PATH_ID = '__root'
|
||||
export const GENERATED_ROUTE_TREE = './.tuono/routeTree.gen.ts'
|
||||
+2
-12
@@ -1,4 +1,3 @@
|
||||
import * as fs from 'fs'
|
||||
import * as fsp from 'fs/promises'
|
||||
import path from 'path'
|
||||
|
||||
@@ -15,14 +14,11 @@ import {
|
||||
removeLayoutSegments,
|
||||
} from './utils'
|
||||
|
||||
import type { Config, RouteNode, RouteSubNode } from './types'
|
||||
import type { Config, RouteNode } from './types'
|
||||
import { ROUTES_FOLDER, ROOT_PATH_ID, GENERATED_ROUTE_TREE } from './constants'
|
||||
|
||||
import { format } from 'prettier'
|
||||
|
||||
const ROUTES_FOLDER = './src/routes/'
|
||||
const ROOT_PATH_ID = '__root'
|
||||
const GENERATED_ROUTE_TREE = './.tuono/routeTree.gen.ts'
|
||||
|
||||
let latestTask = 0
|
||||
|
||||
const defaultConfig: Config = {
|
||||
@@ -168,8 +164,6 @@ export async function routeGenerator(config = defaultConfig): Promise<void> {
|
||||
(d): string => d.routePath,
|
||||
]).filter((d) => ![`/${ROOT_PATH_ID}`].includes(d.routePath || ''))
|
||||
|
||||
const routePiecesByPath: Record<string, RouteSubNode> = {}
|
||||
|
||||
// Loop over the flat list of routeNodes and
|
||||
// build up a tree based on the routeNodes' routePath
|
||||
const routeNodes: RouteNode[] = []
|
||||
@@ -202,10 +196,6 @@ export async function routeGenerator(config = defaultConfig): Promise<void> {
|
||||
return
|
||||
}
|
||||
|
||||
if (node.isLayout && !node.children?.length) {
|
||||
return
|
||||
}
|
||||
|
||||
const route = `${node.variableName}Route`
|
||||
|
||||
if (node.children?.length) {
|
||||
@@ -0,0 +1,47 @@
|
||||
import { routeGenerator } from './generator'
|
||||
|
||||
import { normalize } from 'path'
|
||||
|
||||
import type { Plugin } from 'vite'
|
||||
|
||||
const ROUTES_DIRECTORY_PATH = './src/routes'
|
||||
|
||||
let lock = false
|
||||
|
||||
export default function RouterGenerator(): Plugin {
|
||||
const generate = async (): Promise<void> => {
|
||||
if (lock) return
|
||||
lock = true
|
||||
|
||||
try {
|
||||
await routeGenerator()
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
} finally {
|
||||
lock = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleFile = async (file: string): Promise<void> => {
|
||||
const filePath = normalize(file)
|
||||
|
||||
if (filePath.startsWith(ROUTES_DIRECTORY_PATH)) {
|
||||
await generate()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'vite-plugin-tuono-fs-router',
|
||||
configResolved: async (): Promise<void> => {
|
||||
await generate()
|
||||
},
|
||||
watchChange: async (
|
||||
file: string,
|
||||
context: { event: string },
|
||||
): Promise<void> => {
|
||||
if (['create', 'update', 'delete'].includes(context.event)) {
|
||||
await handleFile(file)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
-11
@@ -11,17 +11,6 @@ export interface RouteNode {
|
||||
variableName?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
export interface RouteSubNode {
|
||||
component?: RouteNode
|
||||
errorComponent?: RouteNode
|
||||
pendingComponent?: RouteNode
|
||||
loader?: RouteNode
|
||||
lazy?: RouteNode
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
folderName: string
|
||||
generatedRouteTree: string
|
||||
@@ -0,0 +1,43 @@
|
||||
import fs from 'fs/promises'
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { routeGenerator } from '../src/generator'
|
||||
|
||||
function makeFolderDir(folder: string) {
|
||||
return process.cwd() + `/tests/generator/${folder}`
|
||||
}
|
||||
|
||||
async function getRouteTreeFileText(folder: string) {
|
||||
const dir = makeFolderDir(folder)
|
||||
return await fs.readFile(dir + '/routeTree.gen.ts', 'utf-8')
|
||||
}
|
||||
|
||||
async function getExpectedRouteTreeFileText(folder: string) {
|
||||
const dir = makeFolderDir(folder)
|
||||
const location = dir + '/routeTree.expected.ts'
|
||||
return await fs.readFile(location, 'utf-8')
|
||||
}
|
||||
|
||||
describe('generator works', async () => {
|
||||
const folderNames = await fs.readdir(process.cwd() + '/tests/generator')
|
||||
|
||||
it.each(folderNames.map((folder) => [folder]))(
|
||||
'should wire-up the routes for a "%s" tree',
|
||||
async (folderName) => {
|
||||
const currentFolder = `${process.cwd()}/tests/generator/${folderName}`
|
||||
|
||||
await routeGenerator({
|
||||
folderName: `${currentFolder}/routes`,
|
||||
generatedRouteTree: `${currentFolder}/routeTree.gen.ts`,
|
||||
})
|
||||
|
||||
const [expectedRouteTree, generatedRouteTree] = await Promise.all([
|
||||
getExpectedRouteTreeFileText(folderName),
|
||||
getRouteTreeFileText(folderName),
|
||||
])
|
||||
|
||||
console.log(generatedRouteTree)
|
||||
|
||||
expect(generatedRouteTree).equal(expectedRouteTree)
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
// This file is auto-generated by Tuono
|
||||
|
||||
import { createRoute, dynamic } from 'tuono'
|
||||
|
||||
import RootImport from './routes/__root'
|
||||
|
||||
const AboutImport = dynamic(() => import('./routes/about'))
|
||||
const IndexImport = dynamic(() => import('./routes/index'))
|
||||
const PostsMyPostImport = dynamic(() => import('./routes/posts/my-post'))
|
||||
|
||||
const rootRoute = createRoute({ isRoot: true, component: RootImport })
|
||||
|
||||
const About = createRoute({ component: AboutImport })
|
||||
const Index = createRoute({ component: IndexImport })
|
||||
const PostsMyPost = createRoute({ component: PostsMyPostImport })
|
||||
|
||||
// Create/Update Routes
|
||||
|
||||
const AboutRoute = About.update({
|
||||
path: '/about',
|
||||
getParentRoute: () => rootRoute,
|
||||
})
|
||||
|
||||
const IndexRoute = Index.update({
|
||||
path: '/',
|
||||
getParentRoute: () => rootRoute,
|
||||
})
|
||||
|
||||
const PostsMyPostRoute = PostsMyPost.update({
|
||||
path: '/posts/my-post',
|
||||
getParentRoute: () => rootRoute,
|
||||
})
|
||||
|
||||
// Create and export the route tree
|
||||
|
||||
export const routeTree = rootRoute.addChildren([
|
||||
IndexRoute,
|
||||
AboutRoute,
|
||||
PostsMyPostRoute,
|
||||
])
|
||||
@@ -0,0 +1 @@
|
||||
/** */
|
||||
@@ -0,0 +1 @@
|
||||
/** */
|
||||
@@ -0,0 +1 @@
|
||||
/** */
|
||||
@@ -0,0 +1 @@
|
||||
/** */
|
||||
@@ -0,0 +1,29 @@
|
||||
// This file is auto-generated by Tuono
|
||||
|
||||
import { createRoute, dynamic } from 'tuono'
|
||||
|
||||
import RootImport from './routes/__root'
|
||||
|
||||
const AboutImport = dynamic(() => import('./routes/about'))
|
||||
const IndexImport = dynamic(() => import('./routes/index'))
|
||||
|
||||
const rootRoute = createRoute({ isRoot: true, component: RootImport })
|
||||
|
||||
const About = createRoute({ component: AboutImport })
|
||||
const Index = createRoute({ component: IndexImport })
|
||||
|
||||
// Create/Update Routes
|
||||
|
||||
const AboutRoute = About.update({
|
||||
path: '/about',
|
||||
getParentRoute: () => rootRoute,
|
||||
})
|
||||
|
||||
const IndexRoute = Index.update({
|
||||
path: '/',
|
||||
getParentRoute: () => rootRoute,
|
||||
})
|
||||
|
||||
// Create and export the route tree
|
||||
|
||||
export const routeTree = rootRoute.addChildren([IndexRoute, AboutRoute])
|
||||
@@ -0,0 +1 @@
|
||||
/** */
|
||||
@@ -0,0 +1 @@
|
||||
/** */
|
||||
@@ -0,0 +1 @@
|
||||
/** */
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineConfig, mergeConfig } from 'vitest/config'
|
||||
import { tanstackBuildConfig } from '@tanstack/config/build'
|
||||
|
||||
const config = defineConfig({})
|
||||
|
||||
export default mergeConfig(
|
||||
config,
|
||||
tanstackBuildConfig({
|
||||
entry: './src/index.ts',
|
||||
srcDir: './src',
|
||||
}),
|
||||
)
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "tuono-lazy-fn-vite-plugin",
|
||||
"version": "0.4.3",
|
||||
"version": "0.4.4",
|
||||
"description": "Plugin for the tuono's lazy fn. Tuono is the react/rust fullstack framework",
|
||||
"scripts": {
|
||||
"dev": "vite build --watch",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "tuono",
|
||||
"version": "0.4.3",
|
||||
"version": "0.4.4",
|
||||
"description": "The react/rust fullstack framework",
|
||||
"scripts": {
|
||||
"dev": "vite build --watch",
|
||||
@@ -88,7 +88,7 @@
|
||||
"@types/node": "^20.12.7",
|
||||
"@vitejs/plugin-react-swc": "^3.7.0",
|
||||
"fast-text-encoding": "^1.0.6",
|
||||
"prettier": "^3.2.4",
|
||||
"tuono-fs-router-vite-plugin": "workspace:*",
|
||||
"tuono-lazy-fn-vite-plugin": "workspace:*",
|
||||
"vite": "^5.2.11",
|
||||
"zustand": "4.4.7"
|
||||
@@ -100,6 +100,7 @@
|
||||
"@types/babel__traverse": "^7.20.6",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"prettier": "^3.2.4",
|
||||
"jsdom": "^24.0.0",
|
||||
"vitest": "^1.5.2"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { build, createServer, InlineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react-swc'
|
||||
import { ViteFsRouter } from './tuono-vite-plugin'
|
||||
import ViteFsRouter from 'tuono-fs-router-vite-plugin'
|
||||
import { LazyLoadingPlugin } from 'tuono-lazy-fn-vite-plugin'
|
||||
|
||||
const BASE_CONFIG: InlineConfig = {
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
/**
|
||||
* Overview:
|
||||
*
|
||||
* src/routes = Routes entry point
|
||||
* src/routes/layout.tsx = Shared layout
|
||||
* src/routes/index.tsx = xyz.com
|
||||
* src/routes/about.tsx = xyz.com/about
|
||||
* src/routes/about-loading.tsx = xyz.com/about - while loading data
|
||||
* src/routes/about/index.tsx = xyz.com/about
|
||||
* src/routes/posts/[slug].tsx = xyz.com/posts/my-lovely-post
|
||||
* src/routes/posts/[...params].tsx = xyz.com/posts/my-lovely-post/commend-id-304
|
||||
* src/routes/404.tsx = Not found
|
||||
*
|
||||
* public/ = Public files
|
||||
*
|
||||
* All the routes are lazy loaded!
|
||||
*/
|
||||
|
||||
import { routeGenerator } from './generator'
|
||||
|
||||
export { routeGenerator }
|
||||
@@ -1,282 +0,0 @@
|
||||
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
|
||||
}
|
||||
|
||||
// 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<CompileOutput> => {
|
||||
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 {
|
||||
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 +0,0 @@
|
||||
export const SPLIT_PREFIX = 'sp-'
|
||||
@@ -1,298 +0,0 @@
|
||||
// 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: any) {
|
||||
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
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
import { fileURLToPath, pathToFileURL } from 'url'
|
||||
import { routeGenerator } from '../routes-generator'
|
||||
|
||||
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 ROUTES_DIRECTORY_PATH = './src/routes'
|
||||
|
||||
let lock = false
|
||||
|
||||
export function RouterGenerator(): Plugin {
|
||||
const generate = async (): Promise<void> => {
|
||||
if (lock) return
|
||||
lock = true
|
||||
|
||||
try {
|
||||
// TODO: generator function
|
||||
await routeGenerator()
|
||||
} catch (err) {
|
||||
} finally {
|
||||
lock = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleFile = async (file: string): Promise<void> => {
|
||||
const filePath = normalize(file)
|
||||
|
||||
if (filePath.startsWith(ROUTES_DIRECTORY_PATH)) {
|
||||
// TODO: generator function
|
||||
await generate()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'vite-plugin-tuono-fs-router-generator',
|
||||
configResolved: async (): Promise<void> => {
|
||||
await generate()
|
||||
},
|
||||
watchChange: async (
|
||||
file: string,
|
||||
context: { event: string },
|
||||
): Promise<void> => {
|
||||
if (['create', 'update', 'delete'].includes(context.event)) {
|
||||
await handleFile(file)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function RouterCodeSplitter(): Plugin {
|
||||
const ROOT: string = process.cwd()
|
||||
|
||||
return {
|
||||
name: 'vite-plugin-tuono-fs-router-code-splitter',
|
||||
enforce: 'pre',
|
||||
resolveId(source): string | null {
|
||||
if (source.startsWith(SPLIT_PREFIX + ':')) {
|
||||
return source.replace(SPLIT_PREFIX + ':', '')
|
||||
}
|
||||
return null
|
||||
},
|
||||
async transform(code, id): Promise<any> {
|
||||
const url = pathToFileURL(id)
|
||||
url.searchParams.delete('v')
|
||||
id = fileURLToPath(url).replace(/\\/g, '/')
|
||||
|
||||
const compile = makeCompile({ root: ROOT })
|
||||
|
||||
if (id.includes(SPLIT_PREFIX)) {
|
||||
const compiled = await splitFile({
|
||||
code,
|
||||
compile,
|
||||
filename: id,
|
||||
})
|
||||
|
||||
return compiled
|
||||
}
|
||||
|
||||
return null
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function ViteFsRouter(): Plugin[] {
|
||||
return [RouterGenerator(), RouterCodeSplitter()]
|
||||
}
|
||||
Reference in New Issue
Block a user