diff --git a/crates/tuono/src/app.rs b/crates/tuono/src/app.rs index dd813943..5d428623 100644 --- a/crates/tuono/src/app.rs +++ b/crates/tuono/src/app.rs @@ -21,11 +21,17 @@ const ROUTES_FOLDER_PATH: &str = "\\src\\routes"; #[cfg(target_os = "windows")] const BUILD_JS_SCRIPT: &str = ".\\node_modules\\.bin\\tuono-build-prod.cmd"; +#[cfg(target_os = "windows")] +const BUILD_TUONO_CONFIG: &str = ".\\node_modules\\.bin\\tuono-build-config.cmd"; + #[cfg(not(target_os = "windows"))] const ROUTES_FOLDER_PATH: &str = "/src/routes"; #[cfg(not(target_os = "windows"))] const BUILD_JS_SCRIPT: &str = "./node_modules/.bin/tuono-build-prod"; +#[cfg(not(target_os = "windows"))] +const BUILD_TUONO_CONFIG: &str = "./node_modules/.bin/tuono-build-config"; + #[derive(Debug)] pub struct App { pub route_map: HashMap, @@ -147,6 +153,13 @@ impl App { .expect("Failed to run the rust server") } + pub fn build_tuono_config(&self) -> Result { + Command::new(BUILD_TUONO_CONFIG) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + } pub fn get_used_http_methods(&self) -> HashSet { let mut acc = HashSet::new(); diff --git a/crates/tuono/src/cli.rs b/crates/tuono/src/cli.rs index 6703f09c..2029d7dc 100644 --- a/crates/tuono/src/cli.rs +++ b/crates/tuono/src/cli.rs @@ -87,7 +87,9 @@ pub fn app() -> std::io::Result<()> { Actions::Dev => { check_ports(Mode::Dev); - let _ = init_tuono_folder(Mode::Dev)?; + let app = init_tuono_folder(Mode::Dev)?; + app.build_tuono_config() + .expect("Failed to build tuono.config.ts"); watch::watch().unwrap(); } @@ -105,6 +107,9 @@ pub fn app() -> std::io::Result<()> { return Ok(()); } + app.build_tuono_config() + .expect("Failed to build tuono.config.ts"); + app.build_react_prod(); if ssg { diff --git a/examples/tuono-app/tsconfig.json b/examples/tuono-app/tsconfig.json index a7fc6fbf..8d08b177 100644 --- a/examples/tuono-app/tsconfig.json +++ b/examples/tuono-app/tsconfig.json @@ -20,6 +20,6 @@ "noUnusedParameters": true, "noFallthroughCasesInSwitch": true }, - "include": ["src"], + "include": ["src", "tuono.config.ts"], "references": [{ "path": "./tsconfig.node.json" }] } diff --git a/examples/tuono-app/tuono.config.ts b/examples/tuono-app/tuono.config.ts new file mode 100644 index 00000000..a70b2924 --- /dev/null +++ b/examples/tuono-app/tuono.config.ts @@ -0,0 +1,5 @@ +import type { TuonoConfig } from 'tuono/config' + +const config: TuonoConfig = {} + +export default config diff --git a/examples/tuono-tutorial/src/routes/index.tsx b/examples/tuono-tutorial/src/routes/index.tsx index 5700f0ee..dc08aced 100644 --- a/examples/tuono-tutorial/src/routes/index.tsx +++ b/examples/tuono-tutorial/src/routes/index.tsx @@ -2,7 +2,7 @@ import type { JSX } from 'react' import { Head, type TuonoProps } from 'tuono' -import PokemonLink from '../components/PokemonLink' +import PokemonLink from '@/components/PokemonLink' interface Pokemon { name: string diff --git a/examples/tuono-tutorial/src/routes/pokemons/[pokemon].tsx b/examples/tuono-tutorial/src/routes/pokemons/[pokemon].tsx index 251f1efe..cfb2ea72 100644 --- a/examples/tuono-tutorial/src/routes/pokemons/[pokemon].tsx +++ b/examples/tuono-tutorial/src/routes/pokemons/[pokemon].tsx @@ -1,7 +1,7 @@ import type { JSX } from 'react' import { Head, type TuonoProps } from 'tuono' -import PokemonView from '../../components/PokemonView' +import PokemonView from '@/components/PokemonView' interface Pokemon { name: string diff --git a/examples/tuono-tutorial/tsconfig.json b/examples/tuono-tutorial/tsconfig.json index a7fc6fbf..e7ce4768 100644 --- a/examples/tuono-tutorial/tsconfig.json +++ b/examples/tuono-tutorial/tsconfig.json @@ -18,8 +18,11 @@ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true + "noFallthroughCasesInSwitch": true, + "paths": { + "@/*": ["./src/*"] + } }, - "include": ["src"], + "include": ["src", "tuono.config.ts"], "references": [{ "path": "./tsconfig.node.json" }] } diff --git a/examples/tuono-tutorial/tuono.config.ts b/examples/tuono-tutorial/tuono.config.ts new file mode 100644 index 00000000..cd34c2c6 --- /dev/null +++ b/examples/tuono-tutorial/tuono.config.ts @@ -0,0 +1,11 @@ +import type { TuonoConfig } from 'tuono/config' + +const config: TuonoConfig = { + vite: { + alias: { + '@': 'src', + }, + }, +} + +export default config diff --git a/examples/with-mdx/tsconfig.json b/examples/with-mdx/tsconfig.json index a7fc6fbf..8d08b177 100644 --- a/examples/with-mdx/tsconfig.json +++ b/examples/with-mdx/tsconfig.json @@ -20,6 +20,6 @@ "noUnusedParameters": true, "noFallthroughCasesInSwitch": true }, - "include": ["src"], + "include": ["src", "tuono.config.ts"], "references": [{ "path": "./tsconfig.node.json" }] } diff --git a/examples/with-mdx/tuono.config.ts b/examples/with-mdx/tuono.config.ts new file mode 100644 index 00000000..a70b2924 --- /dev/null +++ b/examples/with-mdx/tuono.config.ts @@ -0,0 +1,5 @@ +import type { TuonoConfig } from 'tuono/config' + +const config: TuonoConfig = {} + +export default config diff --git a/packages/tuono/bin/build-config.js b/packages/tuono/bin/build-config.js new file mode 100644 index 00000000..604c19eb --- /dev/null +++ b/packages/tuono/bin/build-config.js @@ -0,0 +1,5 @@ +#!/usr/bin/env node + +import { buildConfig } from '../dist/esm/build/index.js' + +buildConfig() diff --git a/packages/tuono/package.json b/packages/tuono/package.json index b8d80e00..0c118910 100644 --- a/packages/tuono/package.json +++ b/packages/tuono/package.json @@ -9,7 +9,9 @@ "lint": "eslint --ext .ts,.tsx ./src -c ../../.eslintrc", "format": "prettier -u --write --ignore-unknown '**/*'", "format:check": "prettier --check --ignore-unknown '**/*'", - "types": "tsc --noEmit" + "types": "tsc --noEmit", + "test:watch": "vitest", + "test": "vitest run" }, "type": "module", "types": "dist/esm/index.d.ts", @@ -26,6 +28,16 @@ "default": "./dist/cjs/build/index.js" } }, + "./config": { + "import": { + "types": "./dist/esm/config/index.d.ts", + "default": "./dist/esm/config/index.js" + }, + "require": { + "types": "./dist/cjs/config/index.d.ts", + "default": "./dist/cjs/config/index.js" + } + }, "./ssr": { "import": { "types": "./dist/esm/ssr/index.d.ts", @@ -61,7 +73,8 @@ "bin": { "tuono-dev-ssr": "./bin/dev-ssr.js", "tuono-dev-watch": "./bin/watch.js", - "tuono-build-prod": "./bin/build-prod.js" + "tuono-build-prod": "./bin/build-prod.js", + "tuono-build-config": "./bin/build-config.js" }, "files": [ "dist", diff --git a/packages/tuono/src/build/constants.ts b/packages/tuono/src/build/constants.ts new file mode 100644 index 00000000..b82bc74f --- /dev/null +++ b/packages/tuono/src/build/constants.ts @@ -0,0 +1,3 @@ +export const DOT_TUONO_FOLDER_NAME = '.tuono' +export const CONFIG_FOLDER_NAME = 'config' +export const CONFIG_FILE_NAME = 'config.mjs' diff --git a/packages/tuono/src/build/index.ts b/packages/tuono/src/build/index.ts index c91769c9..5458a9ea 100644 --- a/packages/tuono/src/build/index.ts +++ b/packages/tuono/src/build/index.ts @@ -1,8 +1,11 @@ -import { build, createServer, InlineConfig } from 'vite' +import { build, createServer, InlineConfig, mergeConfig } from 'vite' import react from '@vitejs/plugin-react-swc' import ViteFsRouter from 'tuono-fs-router-vite-plugin' import { LazyLoadingPlugin } from 'tuono-lazy-fn-vite-plugin' import mdx from '@mdx-js/rollup' +import { loadConfig, blockingAsync } from './utils' + +const VITE_PORT = 3001 const BASE_CONFIG: InlineConfig = { root: '.tuono', @@ -22,91 +25,133 @@ const BASE_CONFIG: InlineConfig = { ], } -const VITE_PORT = 3001 - -export function developmentSSRBundle() { - ;(async () => { - await build({ - ...BASE_CONFIG, - build: { - ssr: true, - minify: false, - outDir: 'server', - emptyOutDir: true, - rollupOptions: { - input: './.tuono/server-main.tsx', - // Silent all logs - onLog() {}, - output: { - entryFileNames: 'dev-server.js', - format: 'iife', +const developmentSSRBundle = () => { + blockingAsync(async () => { + const config = await loadConfig() + await build( + mergeConfig(BASE_CONFIG, { + resolve: { + alias: config.vite?.alias || {}, + }, + build: { + ssr: true, + minify: false, + outDir: 'server', + emptyOutDir: true, + rollupOptions: { + input: './.tuono/server-main.tsx', + // Silent all logs + onLog() {}, + output: { + entryFileNames: 'dev-server.js', + format: 'iife', + }, }, }, - }, - ssr: { - target: 'webworker', - noExternal: true, - }, - }) - })() + ssr: { + target: 'webworker', + noExternal: true, + }, + }), + ) + }) } -export function developmentCSRWatch() { - ;(async () => { - const server = await createServer({ - ...BASE_CONFIG, - // Entry point for the development vite proxy - base: '/vite-server/', +const developmentCSRWatch = () => { + blockingAsync(async () => { + const config = await loadConfig() + const server = await createServer( + mergeConfig(BASE_CONFIG, { + resolve: { + alias: config.vite?.alias || {}, + }, + // Entry point for the development vite proxy + base: '/vite-server/', - server: { - port: VITE_PORT, - strictPort: true, - }, - build: { - manifest: true, - emptyOutDir: true, - rollupOptions: { - input: './.tuono/client-main.tsx', + server: { + port: VITE_PORT, + strictPort: true, }, - }, - }) + build: { + manifest: true, + emptyOutDir: true, + rollupOptions: { + input: './.tuono/client-main.tsx', + }, + }, + }), + ) await server.listen() - })() + }) } -export function buildProd() { - ;(async () => { - await build({ - ...BASE_CONFIG, - build: { - manifest: true, - emptyOutDir: true, - outDir: '../out/client', - rollupOptions: { - input: './.tuono/client-main.tsx', +const buildProd = () => { + blockingAsync(async () => { + const config = await loadConfig() + + await build( + mergeConfig(BASE_CONFIG, { + resolve: { + alias: config.vite?.alias || {}, }, - }, - }) + build: { + manifest: true, + emptyOutDir: true, + outDir: '../out/client', + rollupOptions: { + input: './.tuono/client-main.tsx', + }, + }, + }), + ) + + await build( + mergeConfig(BASE_CONFIG, { + resolve: { + alias: config.vite?.alias || {}, + }, + build: { + ssr: true, + minify: true, + outDir: '../out/server', + emptyOutDir: true, + rollupOptions: { + input: './.tuono/server-main.tsx', + output: { + entryFileNames: 'prod-server.js', + format: 'iife', + }, + }, + }, + ssr: { + target: 'webworker', + noExternal: true, + }, + }), + ) + }) +} +const buildConfig = () => { + blockingAsync(async () => { await build({ - ...BASE_CONFIG, + root: '.tuono', + logLevel: 'silent', + cacheDir: 'cache', + envDir: '../', build: { ssr: true, - minify: true, - outDir: '../out/server', + outDir: 'config', emptyOutDir: true, rollupOptions: { - input: './.tuono/server-main.tsx', + input: './tuono.config.ts', output: { - entryFileNames: 'prod-server.js', - format: 'iife', + entryFileNames: 'config.mjs', }, }, }, - ssr: { - target: 'webworker', - noExternal: true, - }, }) - })() + }) } + +export { buildProd, buildConfig, developmentCSRWatch, developmentSSRBundle } diff --git a/packages/tuono/src/build/utils.spec.ts b/packages/tuono/src/build/utils.spec.ts new file mode 100644 index 00000000..a327f769 --- /dev/null +++ b/packages/tuono/src/build/utils.spec.ts @@ -0,0 +1,112 @@ +import path from 'node:path' + +import { describe, expect, it, vi } from 'vitest' + +import type { TuonoConfig } from '../config' + +import { loadConfig, normalizeConfig } from './utils' + +const PROCESS_CWD_MOCK = 'PROCESS_CWD_MOCK' + +vi.spyOn(process, 'cwd').mockReturnValue(PROCESS_CWD_MOCK) + +describe('loadConfig', () => { + it('should error if the config does not exist', async () => { + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}) + await loadConfig() + + expect(consoleErrorSpy).toHaveBeenCalledTimes(2) + }) +}) + +describe('normalizeConfig - vite - alias', () => { + it('should return the config as is, adding `vite` with alias', () => { + const config: TuonoConfig = {} + expect(normalizeConfig(config)).toStrictEqual({ + vite: { alias: undefined }, + }) + }) + + it('should return an empty config if invalid values are provided', () => { + // @ts-expect-error testing invalid config + expect(normalizeConfig({ invalid: true })).toStrictEqual({ + vite: { alias: undefined }, + }) + }) + + it('should not modify alias pointing to packages', () => { + const libraryName = '@tabler/icons-react' + const libraryAlias = '@tabler/icons-react/dist/esm/icons/index.mjs' + const config: TuonoConfig = { + vite: { alias: { [libraryName]: libraryAlias } }, + } + expect(normalizeConfig(config)).toStrictEqual({ + vite: { + alias: { + '@tabler/icons-react': '@tabler/icons-react/dist/esm/icons/index.mjs', + }, + }, + }) + }) + + it('should transform relative paths to absolute path relative to process.cwd()', () => { + const config: TuonoConfig = { + vite: { alias: { '@': './src', '@no-prefix': 'src' } }, + } + + expect(normalizeConfig(config)).toStrictEqual({ + vite: { + alias: { + '@': path.join(PROCESS_CWD_MOCK, 'src'), + '@no-prefix': path.join(PROCESS_CWD_MOCK, 'src'), + }, + }, + }) + }) + + it('should not transform alias with absolute path', () => { + const config: TuonoConfig = { + vite: { alias: { '@1': '/src/pippo', '@2': 'file://pluto' } }, + } + expect(normalizeConfig(config)).toStrictEqual({ + vite: { + alias: { + '@1': '/src/pippo', + '@2': 'file://pluto', + }, + }, + }) + }) + + it('should apply previuos behaviuor when using alias as list', () => { + const config: TuonoConfig = { + vite: { + alias: [ + { find: '1', replacement: '@tabler/icons-react-fun' }, + { find: '2', replacement: './src' }, + { find: '3', replacement: 'file://pluto' }, + ], + }, + } + expect(normalizeConfig(config)).toStrictEqual({ + vite: { + alias: [ + { + find: '1', + replacement: '@tabler/icons-react-fun', + }, + { + find: '2', + replacement: path.join(PROCESS_CWD_MOCK, 'src'), + }, + { + find: '3', + replacement: 'file://pluto', + }, + ], + }, + }) + }) +}) diff --git a/packages/tuono/src/build/utils.ts b/packages/tuono/src/build/utils.ts new file mode 100644 index 00000000..89aa7639 --- /dev/null +++ b/packages/tuono/src/build/utils.ts @@ -0,0 +1,104 @@ +import path from 'path' + +import type { AliasOptions } from 'vite' + +import type { TuonoConfig } from '../config' + +import { + DOT_TUONO_FOLDER_NAME, + CONFIG_FOLDER_NAME, + CONFIG_FILE_NAME, +} from './constants' +import { pathToFileURL } from 'url' + +/** + * Normalize vite alias option: + * - If the path starts with `src` folder, transform it to absolute, prepending the tuono root folder + * - If the path is absolute, remove the ".tuono/config/" path from it + * - Otherwise leave the path untouched + */ +const normalizeAliasPath = (aliasPath: string): string => { + if (aliasPath.startsWith('./src') || aliasPath.startsWith('src')) { + return path.join(process.cwd(), aliasPath) + } + + if (path.isAbsolute(aliasPath)) { + return aliasPath.replace( + path.join(DOT_TUONO_FOLDER_NAME, CONFIG_FOLDER_NAME), + '', + ) + } + + return aliasPath +} + +/** + * From a given vite aliasOptions apply {@link normalizeAliasPath} for each alias. + * + * The config is bundled by `vite` and emitted inside {@link DOT_TUONO_FOLDER_NAME}/{@link CONFIG_FOLDER_NAME}. + * According to this, we have to ensure that the aliases provided by the user are updated to refer to the right folders. + * + * @see https://github.com/Valerioageno/tuono/pull/153#issuecomment-2508142877 + */ +const normalizeViteAlias = (alias?: AliasOptions): AliasOptions | undefined => { + if (!alias) return + + if (Array.isArray(alias)) { + return alias.map(({ replacement, ...userAliasDefinition }) => ({ + ...userAliasDefinition, + replacement: normalizeAliasPath(replacement), + })) + } + + if (typeof alias === 'object') { + let normalizedAlias: AliasOptions = {} + for (let [key, value] of Object.entries(alias)) { + normalizedAlias[key] = normalizeAliasPath(value) + } + return normalizedAlias + } + + return alias +} + +/** + * Wrapper function to normalize the tuono.config.ts file + * + * @warning Exported for unit test. + * There is no easy way to mock the module export and change it in every test + * and also testing the error + */ +export const normalizeConfig = (config: TuonoConfig): TuonoConfig => { + return { + vite: { + alias: normalizeViteAlias(config?.vite?.alias), + }, + } +} + +export const loadConfig = async (): Promise => { + try { + const configFile = await import( + pathToFileURL( + path.join( + process.cwd(), + DOT_TUONO_FOLDER_NAME, + CONFIG_FOLDER_NAME, + CONFIG_FILE_NAME, + ), + ).href + ) + + return normalizeConfig(configFile.default) + } catch (err) { + console.error('Failed to load tuono.config.ts') + console.error(err) + return {} + } +} + +export const blockingAsync = (callback: () => Promise) => { + ;(async () => { + await callback() + })() +} diff --git a/packages/tuono/src/config/index.ts b/packages/tuono/src/config/index.ts new file mode 100644 index 00000000..b7c6b20d --- /dev/null +++ b/packages/tuono/src/config/index.ts @@ -0,0 +1 @@ +export type { TuonoConfig } from './types' diff --git a/packages/tuono/src/config/types.ts b/packages/tuono/src/config/types.ts new file mode 100644 index 00000000..06a29e27 --- /dev/null +++ b/packages/tuono/src/config/types.ts @@ -0,0 +1,7 @@ +import type { AliasOptions } from 'vite' + +export interface TuonoConfig { + vite?: { + alias?: AliasOptions + } +} diff --git a/packages/tuono/vite.config.ts b/packages/tuono/vite.config.ts index d260b490..854eb189 100644 --- a/packages/tuono/vite.config.ts +++ b/packages/tuono/vite.config.ts @@ -14,6 +14,7 @@ export default mergeConfig( entry: [ './src/index.ts', './src/build/index.ts', + './src/config/index.ts', './src/ssr/index.tsx', './src/hydration/index.tsx', ],