From bc444929ed659a606ee177593e829fb337531a92 Mon Sep 17 00:00:00 2001 From: Valerio Ageno <51341197+Valerioageno@users.noreply.github.com> Date: Sun, 20 Apr 2025 11:04:48 +0200 Subject: [PATCH] feat: add automatic types generation (#703) --- .github/workflows/examples-ci.yml | 24 +- crates/tuono/Cargo.toml | 2 + crates/tuono/src/app.rs | 3 +- crates/tuono/src/commands/dev.rs | 66 +++-- crates/tuono/src/lib.rs | 2 + crates/tuono/src/process_manager.rs | 4 +- crates/tuono/src/source_builder.rs | 26 +- crates/tuono/src/symbols.rs | 29 ++ crates/tuono/src/typescript/file_types.rs | 99 +++++++ crates/tuono/src/typescript/mod.rs | 8 + crates/tuono/src/typescript/parser/mod.rs | 6 + .../tuono/src/typescript/parser/parse_enum.rs | 240 ++++++++++++++++ .../src/typescript/parser/parse_struct.rs | 214 +++++++++++++++ crates/tuono/src/typescript/parser/utils.rs | 217 +++++++++++++++ crates/tuono/src/typescript/types_jar.rs | 259 ++++++++++++++++++ crates/tuono/src/typescript/utils.rs | 71 +++++ crates/tuono_lib/src/lib.rs | 2 +- crates/tuono_lib/src/payload.rs | 2 +- crates/tuono_lib_macros/src/lib.rs | 10 + e2e/fixtures/base/src/routes/index.rs | 4 +- e2e/fixtures/base/src/routes/index.tsx | 7 +- e2e/fixtures/base/tsconfig.json | 2 +- examples/tuono-app/tsconfig.json | 2 +- examples/tuono-tutorial/tsconfig.json | 2 +- examples/with-mdx/tsconfig.json | 2 +- examples/with-tailwind/tsconfig.json | 2 +- 26 files changed, 1246 insertions(+), 59 deletions(-) create mode 100644 crates/tuono/src/symbols.rs create mode 100644 crates/tuono/src/typescript/file_types.rs create mode 100644 crates/tuono/src/typescript/mod.rs create mode 100644 crates/tuono/src/typescript/parser/mod.rs create mode 100644 crates/tuono/src/typescript/parser/parse_enum.rs create mode 100644 crates/tuono/src/typescript/parser/parse_struct.rs create mode 100644 crates/tuono/src/typescript/parser/utils.rs create mode 100644 crates/tuono/src/typescript/types_jar.rs create mode 100644 crates/tuono/src/typescript/utils.rs diff --git a/.github/workflows/examples-ci.yml b/.github/workflows/examples-ci.yml index 1e381509..cfc3a612 100644 --- a/.github/workflows/examples-ci.yml +++ b/.github/workflows/examples-ci.yml @@ -43,7 +43,7 @@ jobs: NAMES=$(find . -mindepth 1 -maxdepth 1 -type d -exec basename {} \;) echo "names=$(echo "$NAMES" | jq -R -s -c 'split("\n")[:-1]')" >> "$GITHUB_OUTPUT" - check_rust: + check_example: needs: - example_list @@ -87,26 +87,11 @@ jobs: working-directory: ./examples/${{ matrix.example_name }} run: ../../target/debug/tuono build - check_typescript: - name: Check typescript - runs-on: ubuntu-latest - timeout-minutes: 15 - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Install NodeJS Dependencies - uses: ./.github/actions/install-node-dependencies - - - name: Build typescript - run: pnpm run build - - name: Check formatting - run: pnpm run examples:format + run: pnpm run format - name: Typecheck - run: pnpm run examples:typecheck + run: pnpm run typecheck ci_ok: name: Examples CI OK @@ -115,8 +100,7 @@ jobs: if: always() needs: - example_list - - check_rust - - check_typescript + - check_example steps: - name: Exit with error if some jobs are not successful run: exit 1 diff --git a/crates/tuono/Cargo.toml b/crates/tuono/Cargo.toml index b631366f..1ee490c1 100644 --- a/crates/tuono/Cargo.toml +++ b/crates/tuono/Cargo.toml @@ -18,6 +18,7 @@ path = "src/lib.rs" [dependencies] clap = { version = "4.5.4", features = ["derive", "cargo"] } +syn = { version = "2.0.100", features = ["full"] } tracing = "0.1.41" tracing-subscriber = {version = "0.3.19", features = ["env-filter"]} miette = "7.2.0" @@ -38,6 +39,7 @@ http = "1.1.0" tuono_internal = {path = "../tuono_internal", version = "0.19.3"} spinners = "4.1.1" console = "0.15.10" +convert_case = "0.8.0" [dev-dependencies] wiremock = "0.6.2" diff --git a/crates/tuono/src/app.rs b/crates/tuono/src/app.rs index efbdc1e4..0b1f3cde 100644 --- a/crates/tuono/src/app.rs +++ b/crates/tuono/src/app.rs @@ -1,7 +1,6 @@ use crate::mode::Mode; use crate::route::Route; -use glob::GlobError; -use glob::glob; +use glob::{GlobError, glob}; use http::Method; use std::collections::hash_set::HashSet; use std::collections::{HashMap, hash_map::Entry}; diff --git a/crates/tuono/src/commands/dev.rs b/crates/tuono/src/commands/dev.rs index f4365fe1..a317fc83 100644 --- a/crates/tuono/src/commands/dev.rs +++ b/crates/tuono/src/commands/dev.rs @@ -1,8 +1,10 @@ +use std::collections::HashSet; use std::fs; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::sync::RwLock; use std::sync::{Arc, Mutex}; use std::time::Duration; +use tracing::error; use tuono_internal::tuono_println; use watchexec_events::Tag; use watchexec_events::filekind::FileEventKind; @@ -32,7 +34,7 @@ fn ssr_reload_needed(path: &Path) -> bool { clippy::await_holding_lock, reason = "At this point there is no other thread waiting for the lock" )] -async fn start_all_processes(process_manager: Arc>) { +async unsafe fn start_all_processes(process_manager: Arc>) { if let Ok(mut pm) = process_manager.lock() { pm.start_dev_processes().await } @@ -59,7 +61,12 @@ pub async fn watch(source_builder: SourceBuilder) -> Result<()> { let env_files = detect_existing_env_files(); - start_all_processes(process_manager.clone()).await; + unsafe { + // It is safe to call this function because here + // only one thread is running and the lock is not + // needed by any other thread. + start_all_processes(process_manager.clone()).await; + } // Remove the spinner sp.stop(); @@ -94,38 +101,59 @@ pub async fn watch(source_builder: SourceBuilder) -> Result<()> { let mut should_reload_rust_server = false; let mut should_refresh_axum_source = false; + // Using HashSet to avoid duplicates + let mut paths_to_refresh_types: HashSet = HashSet::new(); + let mut removed_files_from_types: HashSet = HashSet::new(); + for event in action.events.iter() { for event_type in event.tags.iter() { if let Tag::FileEventKind(kind) = event_type { match kind { - FileEventKind::Remove(_) => { - if event.paths().any(|(path, _)| { - path.extension().is_some_and(|ext| ext == "rs") || - // APIs might define new HTTP methods that requires - // a refresh of the axum source - path.to_str().unwrap_or("").contains("api") - }) { + FileEventKind::Remove(_) => event.paths().for_each(|(path, _)| { + if path.extension().is_some_and(|ext| ext == "rs") { + removed_files_from_types.insert(path.to_path_buf()); should_refresh_axum_source = true; } - } - FileEventKind::Modify(_) => { - if event - .paths() - .any(|(path, _)| path.extension().is_some_and(|ext| ext == "rs")) - { + }), + FileEventKind::Modify(_) => event.paths().for_each(|(path, _)| { + if path.extension().is_some_and(|ext| ext == "rs") { should_reload_rust_server = true; + paths_to_refresh_types.insert(path.to_path_buf()); } - - if event.paths().any(|(path, _)| ssr_reload_needed(path)) { + if ssr_reload_needed(path) { should_reload_ssr_bundle = true; } - } + }), _ => {} } } } } + if !paths_to_refresh_types.is_empty() { + if let Ok(mut builder) = source_builder.write() { + for path in paths_to_refresh_types { + // There is no need to check here if the `Type` trait is + // derived since it will be checked later by the TypeJar struct. + builder.refresh_typescript_file(path) + } + if builder.generate_typescript_file().is_err() { + error!("Failed to generate typescript file"); + }; + } + } + + if !removed_files_from_types.is_empty() { + if let Ok(mut builder) = source_builder.write() { + for path in removed_files_from_types { + builder.remove_typescript_file(path); + } + if builder.generate_typescript_file().is_err() { + error!("Failed to generate typescript file"); + }; + } + } + if should_reload_rust_server || should_refresh_axum_source { tuono_println!("Reloading..."); if should_refresh_axum_source { diff --git a/crates/tuono/src/lib.rs b/crates/tuono/src/lib.rs index 4c97e79f..af8a7611 100644 --- a/crates/tuono/src/lib.rs +++ b/crates/tuono/src/lib.rs @@ -10,3 +10,5 @@ mod mode; mod process_manager; mod route; mod source_builder; +mod symbols; +mod typescript; diff --git a/crates/tuono/src/process_manager.rs b/crates/tuono/src/process_manager.rs index aed8c496..c2c93aab 100644 --- a/crates/tuono/src/process_manager.rs +++ b/crates/tuono/src/process_manager.rs @@ -48,7 +48,7 @@ impl ProcessManager { let mut processes = HashMap::new(); processes.insert( ProcessId::WatchReactSrc, - start_supervisor_job(DEV_WATCH_BIN_SRC, vec![]), + start_supervisor_job(DEV_WATCH_BIN_SRC, Vec::new()), ); processes.insert( @@ -63,7 +63,7 @@ impl ProcessManager { processes.insert( ProcessId::BuildReactSSRSrc, - start_supervisor_job(DEV_SSR_BIN_SRC, vec![]), + start_supervisor_job(DEV_SSR_BIN_SRC, Vec::new()), ); Self { processes } diff --git a/crates/tuono/src/source_builder.rs b/crates/tuono/src/source_builder.rs index ebc292ad..f061e8eb 100644 --- a/crates/tuono/src/source_builder.rs +++ b/crates/tuono/src/source_builder.rs @@ -11,6 +11,7 @@ use crate::app::App; use crate::mode::Mode; use crate::route::AxumInfo; use crate::route::Route; +use crate::typescript::TypesJar; #[cfg(not(target_os = "windows"))] const FALLBACK_HTML: &str = include_str!("../templates/fallback.html"); @@ -59,6 +60,7 @@ pub struct SourceBuilder { pub app: App, mode: Mode, base_path: PathBuf, + types_jar: TypesJar, } impl SourceBuilder { @@ -79,20 +81,23 @@ impl SourceBuilder { Ok(Self { app, mode, + types_jar: TypesJar::from(&base_path), base_path, }) } // Build the source code needed for both build and dev pub fn base_build(&mut self) -> io::Result<()> { - let Self { mode, .. } = &self; + let mode = self.mode.clone(); self.refresh_axum_source()?; let dev_folder = Path::new(DEV_FOLDER); self.create_file(dev_folder.join("server-main.tsx"), SERVER_ENTRY_DATA)?; self.create_file(dev_folder.join("client-main.tsx"), CLIENT_ENTRY_DATA)?; - if mode == &Mode::Dev { + self.types_jar.generate_typescript_file(&self.base_path)?; + + if mode == Mode::Dev { self.app.build_tuono_config()?; let fallback_html = self.build_html_fallback(); self.create_file(PathBuf::from(FALLBACK_HTML_PATH), &fallback_html)?; @@ -165,6 +170,18 @@ impl SourceBuilder { Ok(()) } + pub fn refresh_typescript_file(&mut self, path: PathBuf) { + self.types_jar.refresh_file(path); + } + + pub fn remove_typescript_file(&mut self, path: PathBuf) { + self.types_jar.remove_file(path); + } + + pub fn generate_typescript_file(&mut self) -> io::Result<()> { + self.types_jar.generate_typescript_file(&self.base_path) + } + fn create_routes_declaration(&self) -> String { let routes = &self.app.route_map; let mut route_declarations = String::from("// ROUTE_BUILDER\n"); @@ -244,6 +261,7 @@ mod tests { app: App::new(), mode: Mode::Dev, base_path: PathBuf::new(), + types_jar: TypesJar::default(), } .generate_axum_source(); @@ -251,6 +269,7 @@ mod tests { app: App::new(), mode: Mode::Prod, base_path: PathBuf::new(), + types_jar: TypesJar::default(), } .generate_axum_source(); @@ -264,6 +283,7 @@ mod tests { app: App::new(), mode: Mode::Dev, base_path: PathBuf::new(), + types_jar: TypesJar::default(), } .generate_axum_source(); @@ -276,6 +296,7 @@ mod tests { app: App::new(), mode: Mode::Dev, base_path: PathBuf::new(), + types_jar: TypesJar::default(), }; let mut route = Route::new(String::from("index.tsx")); @@ -300,6 +321,7 @@ mod tests { app, mode: Mode::Dev, base_path: PathBuf::new(), + types_jar: TypesJar::default(), }; let fallback_html = source_builder.build_html_fallback(); diff --git a/crates/tuono/src/symbols.rs b/crates/tuono/src/symbols.rs new file mode 100644 index 00000000..3e80574d --- /dev/null +++ b/crates/tuono/src/symbols.rs @@ -0,0 +1,29 @@ +use std::ops::Deref; + +use syn::Ident; + +pub struct Symbol(&'static str); + +// The trait name that allow automatic struct/enum/type conversion +// to typescript +pub const TYPE_TRAIT: Symbol = Symbol("Type"); + +impl PartialEq for Ident { + fn eq(&self, word: &Symbol) -> bool { + self == word.0 + } +} + +impl PartialEq for &Ident { + fn eq(&self, word: &Symbol) -> bool { + *self == word.0 + } +} + +impl Deref for Symbol { + type Target = &'static str; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} diff --git a/crates/tuono/src/typescript/file_types.rs b/crates/tuono/src/typescript/file_types.rs new file mode 100644 index 00000000..747adf22 --- /dev/null +++ b/crates/tuono/src/typescript/file_types.rs @@ -0,0 +1,99 @@ +use super::utils::has_derive_type; +use crate::typescript::parser::{parse_enum, parse_struct}; +use std::error::Error; +use std::path::PathBuf; +use tracing::trace; + +/// Represents all the valid typescript types found in a file. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FileTypes { + /// Rust file when the type was found + pub file_path: PathBuf, + /// All the types found in the file + /// ready to be printed in the typescript file + pub types_as_string: String, + /// The types found in the file. + /// Used to check that the types are not duplicated across files. + pub types: Vec, +} + +impl TryFrom<(PathBuf, String)> for FileTypes { + type Error = Box; + + fn try_from((file_path, file_str): (PathBuf, String)) -> Result { + trace!("Parsing file: {:?}", &file_path); + let file = syn::parse_file(&file_str)?; + + let mut types_as_string = String::new(); + let mut types = Vec::new(); + + for item in file.items { + match item { + syn::Item::Struct(element) => { + if !has_derive_type(&element.attrs) { + continue; + } + trace!("Found struct in file: {:?}", &file_path); + let (struct_name, typescript_definition) = parse_struct(&element); + types_as_string.push_str(&typescript_definition); + types.push(struct_name); + } + syn::Item::Enum(element) => { + if !has_derive_type(&element.attrs) { + continue; + } + trace!("Found enum in file: {:?}", &file_path); + + let (enum_name, typescript_definition) = parse_enum(&element); + types_as_string.push_str(&typescript_definition); + types.push(enum_name); + } + _ => {} + } + } + + if types_as_string.is_empty() { + return Err("No types found in the file".into()); + } + + Ok(Self { + file_path, + types_as_string, + types, + }) + } +} + +#[cfg(test)] +mod tests { + + use super::*; + + #[test] + fn it_correctly_creates_type_from_pathbuf_and_string() { + let file_path = PathBuf::from("src/types.rs"); + let file_str = r#" + #[derive(Type)] + struct MyStruct { + field1: String, + field2: i32, + } + + #[derive(Type)] + enum MyEnum { + Variant1, + Variant2, + } + "# + .to_string(); + + let ttype = FileTypes::try_from((file_path.clone(), file_str)).unwrap(); + + assert_eq!(ttype.file_path, file_path); + assert_eq!( + ttype.types_as_string, + "export interface MyStruct {\n field1: string;\n field2: number;\n}\nexport type MyEnum = \"Variant1\" | \"Variant2\";\n" + ); + assert_eq!(ttype.types, vec!["MyStruct", "MyEnum"]); + } +} diff --git a/crates/tuono/src/typescript/mod.rs b/crates/tuono/src/typescript/mod.rs new file mode 100644 index 00000000..e39fbf55 --- /dev/null +++ b/crates/tuono/src/typescript/mod.rs @@ -0,0 +1,8 @@ +mod file_types; +pub mod parser; + +mod types_jar; +pub mod utils; + +pub use file_types::*; +pub use types_jar::*; diff --git a/crates/tuono/src/typescript/parser/mod.rs b/crates/tuono/src/typescript/parser/mod.rs new file mode 100644 index 00000000..f7a0ed16 --- /dev/null +++ b/crates/tuono/src/typescript/parser/mod.rs @@ -0,0 +1,6 @@ +mod parse_enum; +mod parse_struct; +pub mod utils; + +pub use parse_enum::*; +pub use parse_struct::*; diff --git a/crates/tuono/src/typescript/parser/parse_enum.rs b/crates/tuono/src/typescript/parser/parse_enum.rs new file mode 100644 index 00000000..8959ba76 --- /dev/null +++ b/crates/tuono/src/typescript/parser/parse_enum.rs @@ -0,0 +1,240 @@ +use crate::typescript::parser::utils::{ + RenameSerdeOptions, get_field_name, parse_generics_to_typescript_string, parse_serde_attribute, + rust_to_typescript_type, should_skip_element, +}; + +/// Parse a rust enum and returns a tuple of the enum name and the +/// enum compiled to a typescript type +pub fn parse_enum(element: &syn::ItemEnum) -> (String, String) { + let enum_name = element.ident.to_string(); + + let generics = parse_generics_to_typescript_string(element.generics.clone().params); + let mut enum_variants: Vec = Vec::new(); + + let rename_option: RenameSerdeOptions = parse_serde_attribute(&element.attrs, "rename_all"); + + for variant in &element.variants { + if should_skip_element(&variant.attrs) { + continue; + } + + let mut parsed_variant = rename_option.transform(variant.ident.to_string()); + + match &variant.fields { + syn::Fields::Named(field) => { + parsed_variant = format!("{{\"{}\": {{ ", parsed_variant); + let mut variant_fields: Vec = Vec::new(); + + for field in &field.named { + if should_skip_element(&field.attrs) { + continue; + } + + let field_name = rename_option.transform(get_field_name(field)); + + let field_type = rust_to_typescript_type(&field.ty); + variant_fields.push(format!("{field_name}: {field_type}")); + } + enum_variants.push(format!( + "{parsed_variant}{} }}}}", + variant_fields.join(", ") + )); + } + syn::Fields::Unnamed(field) => { + let mut variant_fields: Vec = Vec::new(); + for field in &field.unnamed { + let field_type = rust_to_typescript_type(&field.ty); + variant_fields.push(field_type); + } + enum_variants.push(format!( + "{{\"{parsed_variant}\": [{}]}}", + variant_fields.join(", ") + )); + } + syn::Fields::Unit => { + enum_variants.push(format!("\"{parsed_variant}\"")); + } + } + } + + let enum_type = format!( + "export type {enum_name}{generics} = {};\n", + enum_variants.join(" | ") + ); + (enum_name, enum_type) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_correctly_parse_a_simple_enum() { + let enum_str = r#" + #[derive(Type)] + enum MyEnum { + Variant1, + Variant2, + Variant3 + } + "#; + + let parsed_enum = syn::parse_str::(&enum_str).unwrap(); + let (enum_name, typescript_definition) = parse_enum(&parsed_enum); + + assert_eq!(enum_name, "MyEnum"); + assert_eq!( + typescript_definition, + "export type MyEnum = \"Variant1\" | \"Variant2\" | \"Variant3\";\n" + ); + } + + #[test] + fn it_correctly_apply_rename_all_modifier() { + let enum_str = r#" + #[derive(Type)] + #[serde(rename_all = "lowercase")] + enum MyEnum { + Id, + Name, + UserAge + } + "#; + + let parsed_enum = syn::parse_str::(&enum_str).unwrap(); + let (enum_name, typescript_definition) = parse_enum(&parsed_enum); + + assert_eq!(enum_name, "MyEnum"); + assert_eq!( + typescript_definition, + "export type MyEnum = \"id\" | \"name\" | \"userage\";\n" + ); + } + + #[test] + fn it_correctly_skips_a_variant() { + let enum_str = r#" + #[derive(Type)] + #[serde(rename_all = "lowercase")] + enum MyEnum { + Id, + Name, + UserAge, + #[serde(skip)] + SkipMe + } + "#; + + let parsed_enum = syn::parse_str::(&enum_str).unwrap(); + let (_, typescript_definition) = parse_enum(&parsed_enum); + + assert_eq!( + typescript_definition, + "export type MyEnum = \"id\" | \"name\" | \"userage\";\n" + ); + } + + #[test] + fn it_correctly_parse_struct_variant() { + let enum_str = r#" + #[derive(Type)] + enum MyEnum { + Id, + User { + name: String, + age: u32, + }, + } + "#; + + let parsed_enum = syn::parse_str::(&enum_str).unwrap(); + let (_, typescript_definition) = parse_enum(&parsed_enum); + + assert_eq!( + typescript_definition, + "export type MyEnum = \"Id\" | {\"User\": { name: string, age: number }};\n" + ); + + let enum_str = r#" + #[derive(Type)] + enum MyEnum { + Id, + User { + name: String, + #[serde(skip)] + age: u32, + }, + } + "#; + + let parsed_enum = syn::parse_str::(&enum_str).unwrap(); + let (_, typescript_definition) = parse_enum(&parsed_enum); + + assert_eq!( + typescript_definition, + "export type MyEnum = \"Id\" | {\"User\": { name: string }};\n" + ); + + let enum_str = r#" + #[derive(Type)] + enum MyEnum { + Request { + body: Bytes + }, + Response{ + payload: Bytes + }, + } + "#; + + let parsed_enum = syn::parse_str::(&enum_str).unwrap(); + let (_, typescript_definition) = parse_enum(&parsed_enum); + + assert_eq!( + typescript_definition, + "export type MyEnum = {\"Request\": { body: Bytes }} | {\"Response\": { payload: Bytes }};\n" + ); + } + + #[test] + fn it_correctly_parse_the_generic_type() { + let enum_str = r#" + #[derive(Type)] + enum MyEnum { + Id, + User { + name: T, + age: u32 + }, + } + "#; + + let parsed_enum = syn::parse_str::(&enum_str).unwrap(); + let (enum_name, typescript_definition) = parse_enum(&parsed_enum); + + assert_eq!(enum_name, "MyEnum"); + assert_eq!( + typescript_definition, + "export type MyEnum = \"Id\" | {\"User\": { name: T, age: number }};\n" + ); + } + + #[test] + fn it_correctly_parse_the_tuple_variant() { + let enum_str = r#" + #[derive(Type)] + enum MyEnum { + Id, + User(String, u32), + } + "#; + + let parsed_enum = syn::parse_str::(&enum_str).unwrap(); + let (_, typescript_definition) = parse_enum(&parsed_enum); + + assert_eq!( + typescript_definition, + "export type MyEnum = \"Id\" | {\"User\": [string, number]};\n" + ); + } +} diff --git a/crates/tuono/src/typescript/parser/parse_struct.rs b/crates/tuono/src/typescript/parser/parse_struct.rs new file mode 100644 index 00000000..2b086234 --- /dev/null +++ b/crates/tuono/src/typescript/parser/parse_struct.rs @@ -0,0 +1,214 @@ +use crate::typescript::parser::utils::{ + RenameSerdeOptions, get_field_name, parse_generics_to_typescript_string, parse_serde_attribute, + rust_to_typescript_type, +}; + +use super::utils::should_skip_element; + +/// Parse a rust struct and returns a tuple of the struct name and the +/// struct compiled to a typescript interface +pub fn parse_struct(element: &syn::ItemStruct) -> (String, String) { + let struct_name = element.ident.to_string(); + let generics = parse_generics_to_typescript_string(element.generics.clone().params); + + let mut fields_as_string = format!("export interface {struct_name}{generics} {{\n"); + + let rename_option: RenameSerdeOptions = parse_serde_attribute(&element.attrs, "rename_all"); + + for field in &element.fields { + if should_skip_element(&field.attrs) { + continue; + } + + let field_name = get_field_name(field); + + let field_type = rust_to_typescript_type(&field.ty); + + let field_name = rename_option.transform(field_name); + + fields_as_string.push_str(&format!(" {field_name}: {field_type};\n")); + } + + fields_as_string.push_str("}\n"); + + (struct_name, fields_as_string) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_correctly_parses_struct() { + let struct_str = r#" + #[derive(Type)] + struct MyStruct<'a, T, R>{ + field_1: &str, + field2: i32, + field3: Option, + field4: Vec, + record: HashMap<&'a str, i32>, + user: User, + generic: T, + generic2: HashMap<&mut str, R>, + btree: BTreeMap, + } + "#; + + let parsed_struct = syn::parse_str::(struct_str).unwrap(); + let (struct_name, typescript_definition) = parse_struct(&parsed_struct); + + assert_eq!(struct_name, "MyStruct"); + assert_eq!( + typescript_definition, + "export interface MyStruct {\n field_1: string;\n field2: number;\n field3: string | null;\n field4: number[];\n record: Record;\n user: User;\n generic: T;\n generic2: Record;\n btree: Record;\n}\n" + ); + } + + #[test] + fn it_correctly_parse_tuple_fields() { + let struct_str = r#" + #[derive(Type)] + struct MyStruct { + tuple: (i32, i32, String, User), + + }"#; + + let parsed_struct = syn::parse_str::(struct_str).unwrap(); + let (_, typescript_definition) = parse_struct(&parsed_struct); + + assert_eq!( + typescript_definition, + "export interface MyStruct {\n tuple: [number, number, string, User];\n}\n" + ); + } + + #[test] + fn it_correctly_parses_struct_with_no_generics() { + let struct_str = r#" + #[derive(Type)] + struct MyStruct { + field_1: &str, + } + "#; + + let parsed_struct = syn::parse_str::(struct_str).unwrap(); + let (struct_name, typescript_definition) = parse_struct(&parsed_struct); + + assert_eq!(struct_name, "MyStruct"); + assert_eq!( + typescript_definition, + "export interface MyStruct {\n field_1: string;\n}\n" + ); + } + + #[test] + fn it_correctly_turn_the_keys_camel_case() { + let struct_str = r#" + #[derive(Type)] + #[serde(rename_all = "camelCase")] + struct MyStruct { + field_one: &str, + field_two: i32, + } + "#; + + let parsed_struct = syn::parse_str::(struct_str).unwrap(); + let (struct_name, typescript_definition) = parse_struct(&parsed_struct); + + assert_eq!(struct_name, "MyStruct"); + assert_eq!( + typescript_definition, + "export interface MyStruct {\n fieldOne: string;\n fieldTwo: number;\n}\n" + ); + } + + #[test] + fn it_correctly_turn_the_keys_pascal_case() { + let struct_str = r#" + #[derive(Type)] + #[serde(rename_all = "PascalCase")] + struct MyStruct { + field_one: &str, + field_two: i32, + } + "#; + + let parsed_struct = syn::parse_str::(struct_str).unwrap(); + let (struct_name, typescript_definition) = parse_struct(&parsed_struct); + + assert_eq!(struct_name, "MyStruct"); + assert_eq!( + typescript_definition, + "export interface MyStruct {\n FieldOne: string;\n FieldTwo: number;\n}\n" + ); + } + + #[test] + fn it_correctly_retrieve_the_serde_rename_option() { + let struct_str = r#" + #[derive(Type)] + #[serde(rename_all = "PascalCase")] + struct MyStruct { + field_one: &str, + } + "#; + + let parsed_struct = syn::parse_str::(struct_str).unwrap(); + let rename_option: RenameSerdeOptions = + parse_serde_attribute(&parsed_struct.attrs, "rename_all"); + + assert_eq!(rename_option, RenameSerdeOptions::PascalCase); + + let struct_str = r#" + #[derive(Type)] + #[serde(rename_all = "camelCase")] + struct MyStruct { + field_one: &str, + }"#; + let parsed_struct = syn::parse_str::(struct_str).unwrap(); + let rename_option: RenameSerdeOptions = + parse_serde_attribute(&parsed_struct.attrs, "rename_all"); + assert_eq!(rename_option, RenameSerdeOptions::CamelCase); + } + + #[test] + fn it_correctly_identifies_skip_fields() { + let struct_str = r#" + #[derive(Type)] + struct MyStruct { + #[serde(skip)] + field_one: &str, + field_two: i32, + #[serde(skip_serializing)] + field_three: i32, + } + "#; + + let parsed_struct = syn::parse_str::(struct_str).unwrap(); + + let (_, typescript_definition) = parse_struct(&parsed_struct); + assert_eq!( + typescript_definition, + "export interface MyStruct {\n field_two: number;\n}\n" + ); + } + + #[test] + fn it_correctly_override_a_field_name() { + let struct_str = r#" + #[derive(Type)] + struct MyStruct { + #[serde(rename = "field_one")] + field_two: i32, + } + "#; + + let parsed_struct = syn::parse_str::(struct_str).unwrap(); + let (_, typescript_definition) = parse_struct(&parsed_struct); + assert_eq!( + typescript_definition, + "export interface MyStruct {\n field_one: number;\n}\n" + ); + } +} diff --git a/crates/tuono/src/typescript/parser/utils.rs b/crates/tuono/src/typescript/parser/utils.rs new file mode 100644 index 00000000..e5e7843a --- /dev/null +++ b/crates/tuono/src/typescript/parser/utils.rs @@ -0,0 +1,217 @@ +use convert_case::{Case, Casing}; +use std::str::FromStr; +use syn::punctuated::Punctuated; +use syn::token::Comma; +use syn::{GenericArgument, GenericParam, PathArguments}; + +fn type_to_typescript(type_name: &str) -> &str { + match type_name { + "i8" | "i16" | "i32" | "i64" | "i128" | "u8" | "u16" | "u32" | "u64" | "f32" | "f64" + | "isize" | "usize" => "number", + "str" | "String" | "char" => "string", + "bool" => "boolean", + _ => type_name, + } +} + +pub fn get_field_name(field: &syn::Field) -> String { + let field_name: String = parse_serde_attribute(&field.attrs, "rename"); + + if !field_name.is_empty() { + return field_name; + } + + if let Some(field) = field.ident.as_ref() { + field.to_string() + } else { + String::from("unknown") + } +} + +/// Parse the struct generics and return them collected into a "<...>" string. +/// If no generics are present, return an empty string. +pub fn parse_generics_to_typescript_string(generics: Punctuated) -> String { + let generics = generics + .iter() + .map(|param| { + if let syn::GenericParam::Type(type_param) = param { + type_param.ident.to_string() + } else { + String::new() + } + }) + .filter(|name| !name.is_empty()) + .collect::>(); + + if !generics.is_empty() { + return format!("<{}>", generics.join(", ")); + } + + String::new() +} + +/// Parse any "assign" `#[serde(... = "...")]` attribute and return the value of the specified +/// attribute. +pub fn parse_serde_attribute(attrs: &[syn::Attribute], attribute_name: &str) -> T +where + T: Default + FromStr, +{ + for attr in attrs { + if attr.path().is_ident("serde") { + if let Ok(meta) = attr.parse_args::() { + match meta { + syn::Expr::Assign(assign) => { + if let syn::Expr::Path(path) = *assign.left { + if !path.path.is_ident(attribute_name) { + return T::default(); + } + } + if let syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Str(lit_str), + .. + }) = *assign.right + { + return T::from_str(&lit_str.value()).unwrap_or_default(); + } + } + _ => return T::default(), + } + } + } + } + T::default() +} + +/// Check if the element should be skipped based on the presence of +/// `#[serde(skip)]` or `#[serde(skip_serializing)]` attributes. +pub fn should_skip_element(attrs: &[syn::Attribute]) -> bool { + for attr in attrs { + if attr.path().is_ident("serde") { + if let Ok(meta) = attr.parse_args::() { + if meta == "skip" || meta == "skip_serializing" { + return true; + } + } + } + } + false +} + +pub fn rust_to_typescript_type(ty: &syn::Type) -> String { + match ty { + syn::Type::Tuple(tuple) => { + let inner_types: Vec = + tuple.elems.iter().map(rust_to_typescript_type).collect(); + format!("[{}]", inner_types.join(", ")) + } + syn::Type::Path(type_path) => { + if let Some(last_segment) = type_path.path.segments.last() { + let outer_type = last_segment.ident.to_string(); + if let PathArguments::AngleBracketed(args) = &last_segment.arguments { + let inner_types: Vec = args + .args + .iter() + .filter_map(|arg| { + if let GenericArgument::Type(inner_type) = arg { + match inner_type { + syn::Type::Path(inner_type_path) => { + Some(inner_type_path.path.segments[0].ident.to_string()) + } + syn::Type::Reference(reference) => { + if let syn::Type::Path(inner_type_path) = &*reference.elem { + Some(inner_type_path.path.segments[0].ident.to_string()) + } else { + Some("unknown".to_string()) + } + } + _ => Some("unknown".to_string()), + } + } else { + None + } + }) + .collect(); + + match outer_type.as_str() { + "Option" => { + format!("{} | null", type_to_typescript(&inner_types[0])) + } + "Vec" => { + format!("{}[]", type_to_typescript(&inner_types[0])) + } + "HashMap" | "BTreeMap" => { + format!( + "Record<{}, {}>", + type_to_typescript(&inner_types[0]), + type_to_typescript(&inner_types[1]) + ) + } + _ => "unknown".to_string(), + } + } else { + type_to_typescript(&outer_type).to_string() + } + } else { + "unknown".to_string() + } + } + syn::Type::Reference(reference) => { + // Ignore lifetimes and treat references as their base type + if let syn::Type::Path(type_path) = &*reference.elem { + if let Some(base_type) = type_path.path.segments.last() { + type_to_typescript(&base_type.ident.to_string()).to_string() + } else { + "unknown".to_string() + } + } else { + "unknown".to_string() + } + } + _ => "unknown".to_string(), + } +} + +// This enum matches serde's RenameRule enum +#[derive(Debug, Eq, PartialEq, Default)] +pub enum RenameSerdeOptions { + #[default] + None, + LowerCase, + UpperCase, + PascalCase, + CamelCase, + SnakeCase, + ScreamingSnakeCase, + KebabCase, + ScreamingKebabCase, +} + +impl FromStr for RenameSerdeOptions { + type Err = (); + + fn from_str(input: &str) -> Result { + match input { + "lowercase" => Ok(Self::LowerCase), + "UPPERCASE" => Ok(Self::UpperCase), + "PascalCase" => Ok(Self::PascalCase), + "camelCase" => Ok(Self::CamelCase), + "snake_case" => Ok(Self::SnakeCase), + "SCREAMING_SNAKE_CASE" => Ok(Self::ScreamingSnakeCase), + "kebab-case" => Ok(Self::KebabCase), + "SCREAMING-KEBAB-CASE" => Ok(Self::ScreamingKebabCase), + _ => Err(()), + } + } +} + +impl RenameSerdeOptions { + pub fn transform(&self, input: String) -> String { + match self { + Self::LowerCase => input.to_lowercase(), + Self::UpperCase => input.to_uppercase(), + Self::CamelCase => input.to_case(Case::Camel), + Self::PascalCase => input.to_case(Case::Pascal), + _ => input, + } + } +} diff --git a/crates/tuono/src/typescript/types_jar.rs b/crates/tuono/src/typescript/types_jar.rs new file mode 100644 index 00000000..64bb5955 --- /dev/null +++ b/crates/tuono/src/typescript/types_jar.rs @@ -0,0 +1,259 @@ +use crate::symbols::TYPE_TRAIT; +use crate::typescript::FileTypes; +use glob::glob; +use std::collections::HashMap; +use std::env; +use std::fs::read_to_string; +use std::path::{Path, PathBuf}; +use tracing::{error, trace}; +use tuono_internal::tuono_println; + +#[derive(Debug, Clone, Default)] +pub struct TypesJar { + types: Vec, + should_generate_typescript_file: bool, +} + +impl TypesJar { + pub fn new() -> Self { + Self { + types: Vec::new(), + should_generate_typescript_file: true, + } + } +} + +impl TypesJar { + /// Removes all types from the jar that are + /// present in the provided `file_path`. + /// This function is triggered when a file is deleted + pub fn remove_file(&mut self, file_path: PathBuf) { + self.should_generate_typescript_file = true; + self.types.retain(|ttype| ttype.file_path != file_path); + } + + pub fn refresh_file(&mut self, path: PathBuf) { + if let Ok(file_str) = read_to_string(&path) { + if file_str.contains(*TYPE_TRAIT) { + if let Ok(ttype) = FileTypes::try_from((path.clone(), file_str)) { + if Some(&ttype) == self.types.iter().find(|t| t.file_path == path) { + // The new file exactly matches the old one + trace!("File already exists in jar: {:?}", path); + return; + } + trace!("Refreshing: {:?} type", ttype.types); + + self.should_generate_typescript_file = true; + self.remove_file(path); + self.types.push(ttype); + } else { + error!("Failed to parse file: {:?}", path); + } + } else { + // Check if the file exist. In case it is it means that the user + // removed the "Type" derived trait. Hence we have to remove it from + // the jar + self.remove_file(path.clone()); + } + } else { + error!("Failed to read file: {:?}", path); + } + } + + pub fn check_duplicate_types(&self) -> HashMap { + trace!("Checking for duplicated types"); + let mut duplicates: HashMap = HashMap::new(); + let mut paths: Vec<&PathBuf> = Vec::new(); + let mut types: Vec<&Vec> = Vec::new(); + + for file_types in self.types.iter() { + paths.push(&file_types.file_path); + types.push(&file_types.types); + } + + for i in 0..types.len() { + for j in (i + 1)..types.len() { + let types_i = types[i]; + let types_j = types[j]; + + for type_i in types_i { + if types_j.contains(type_i) { + duplicates.insert(type_i.clone(), (paths[j].clone(), paths[i].clone())); + } + } + } + } + + duplicates + } + + fn log_duplicated_types(&self) { + let duplicates = self.check_duplicate_types(); + let base_path = env::current_dir().unwrap_or_default(); + let base_path_str = base_path.to_string_lossy(); + + for (type_name, file_paths) in duplicates.iter() { + let first_file_path = file_paths + .0 + .to_string_lossy() + .replace(&base_path_str.to_string(), ""); + let second_file_path = file_paths + .1 + .to_string_lossy() + .replace(&base_path_str.to_string(), ""); + + tuono_println!("Duplicate \"{}\" type found in files:\n", type_name); + tuono_println!("- {}", first_file_path); + tuono_println!("- {}\n", second_file_path); + } + } + + /// Generate the string containing all the typescript types + /// found in the jar. + fn generate_typescript(&self) -> String { + self.log_duplicated_types(); + let mut typescript = String::from("declare module \"tuono/types\" {\n"); + for ttype in &self.types { + typescript.push_str(&format!( + "// START [{}]\n", + ttype.file_path.to_string_lossy() + )); + typescript.push_str(&ttype.types_as_string); + typescript.push_str(&format!("// END [{}]\n", ttype.file_path.to_string_lossy())); + } + typescript.push_str("}\n"); + typescript + } + + pub fn generate_typescript_file(&mut self, base_path: &Path) -> std::io::Result<()> { + if !self.should_generate_typescript_file { + trace!("No need to create typescript module file"); + return Ok(()); + } + self.should_generate_typescript_file = false; + trace!("Creating typescript module file"); + let typescript = self.generate_typescript(); + let typescript_file_path = base_path.join(".tuono").join("types.ts"); + std::fs::write(typescript_file_path, typescript)?; + + trace!("Typescript module file created"); + Ok(()) + } +} + +impl From<&PathBuf> for TypesJar { + /// Fill the TypesJar with all the Rust files found within + /// the provided `base_path`. + fn from(base_path: &PathBuf) -> Self { + let mut jar = Self::new(); + + if let Some(path) = base_path.join("src/**/*.rs").to_str() { + if let Ok(files) = glob(path) { + files.for_each(|path| { + let file_path = path.unwrap_or_default(); + if let Ok(file_str) = read_to_string(&file_path) { + if !file_str.contains(*TYPE_TRAIT) { + return; + } + if let Ok(ttype) = FileTypes::try_from((file_path.clone(), file_str)) { + jar.types.push(ttype); + } else { + error!("Failed to parse file: {:?}", file_path); + } + } else { + error!("Failed to read file: {:?}", file_path); + } + }); + } else { + error!("Failed to read glob pattern: {:?}", path); + } + } + jar + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_correctly_finds_duplicate_types() { + let file_type1 = FileTypes { + file_path: PathBuf::from("src/types1.rs"), + types_as_string: String::from("type1"), + types: vec![ + String::from("Type1"), + String::from("Type2"), + String::from("Type3"), + ], + }; + let file_type2 = FileTypes { + file_path: PathBuf::from("src/types2.rs"), + types_as_string: String::from("type1"), + types: vec![ + String::from("Type1"), + String::from("Type2"), + String::from("Type4"), + ], + }; + + let file_type3 = FileTypes { + file_path: PathBuf::from("src/types2.rs"), + types_as_string: String::from("type1"), + types: vec![String::from("Type3")], + }; + + let mut jar = TypesJar::new(); + jar.types.push(file_type1); + jar.types.push(file_type2); + jar.types.push(file_type3); + + let result = jar.check_duplicate_types(); + + assert!( + result + .keys() + .collect::>() + .contains(&&"Type1".to_string()) + ); + assert!( + result + .keys() + .collect::>() + .contains(&&"Type2".to_string()) + ); + assert!( + result + .keys() + .collect::>() + .contains(&&"Type3".to_string()) + ); + + assert!( + !result + .keys() + .collect::>() + .contains(&&"Type4".to_string()) + ); + } + + #[test] + fn it_correctly_removes_types() { + let mut jar = TypesJar::new(); + let file_path = PathBuf::from("src/types.rs"); + let file_type = FileTypes { + file_path: file_path.clone(), + types_as_string: String::from("type1"), + types: vec![String::from("Type1")], + }; + assert!(jar.should_generate_typescript_file); + //Force file generation to false + jar.should_generate_typescript_file = false; + + jar.types.push(file_type); + jar.remove_file(file_path.clone()); + + assert!(jar.should_generate_typescript_file); + assert_eq!(jar.types.len(), 0); + } +} diff --git a/crates/tuono/src/typescript/utils.rs b/crates/tuono/src/typescript/utils.rs new file mode 100644 index 00000000..1aa69c50 --- /dev/null +++ b/crates/tuono/src/typescript/utils.rs @@ -0,0 +1,71 @@ +use crate::symbols::TYPE_TRAIT; +use syn::{Attribute, Meta}; + +pub fn has_derive_type(attrs: &[Attribute]) -> bool { + for attr in attrs { + if let Meta::List(meta_list) = &attr.meta { + if meta_list.path.is_ident("derive") { + for nested_meta in meta_list + .parse_args_with( + syn::punctuated::Punctuated::::parse_terminated, + ) + .unwrap_or_default() + { + if let Meta::Path(path) = nested_meta { + if path.is_ident(&TYPE_TRAIT) { + return true; + } + } + } + } + } + } + false +} + +#[cfg(test)] +mod tests { + + use super::*; + use syn::{ItemEnum, ItemStruct, ItemType, parse_quote}; + + #[test] + fn it_correctly_checks_if_derive_type_is_present() { + let test_struct: ItemStruct = parse_quote! { + #[derive(Type)] + struct MyStruct; + }; + + assert!(has_derive_type(&test_struct.attrs)); + + let test_type: ItemType = parse_quote! { + #[derive(Type)] + type MyType = i32; + }; + + assert!(has_derive_type(&test_type.attrs)); + + let test_enum: ItemEnum = parse_quote! { + #[derive(Type)] + enum MyEnunType { + Variant1, + Variant2, + } + }; + + assert!(has_derive_type(&test_enum.attrs)); + + let test_struct_without_type: ItemStruct = parse_quote! { + struct MyStruct; + }; + + assert!(!has_derive_type(&test_struct_without_type.attrs)); + + let multi_derived_trait: ItemStruct = parse_quote! { + #[derive(Type, Serialize, Debug)] + struct MyStruct; + }; + + assert!(has_derive_type(&multi_derived_trait.attrs)); + } +} diff --git a/crates/tuono_lib/src/lib.rs b/crates/tuono_lib/src/lib.rs index 30083a10..c4af4d33 100644 --- a/crates/tuono_lib/src/lib.rs +++ b/crates/tuono_lib/src/lib.rs @@ -22,7 +22,7 @@ pub use payload::Payload; pub use request::Request; pub use response::{Props, Response}; pub use server::{Server, tuono_internal_init_v8_platform}; -pub use tuono_lib_macros::{api, handler}; +pub use tuono_lib_macros::{Type, api, handler}; // Re-exports pub use axum; diff --git a/crates/tuono_lib/src/payload.rs b/crates/tuono_lib/src/payload.rs index 977d669a..12dd7c4a 100644 --- a/crates/tuono_lib/src/payload.rs +++ b/crates/tuono_lib/src/payload.rs @@ -107,7 +107,7 @@ impl<'a> Payload<'a> { .filter(|path| !path.is_empty()) .collect::>(); - let mut route_segments_collector: Vec<&str> = vec![]; + let mut route_segments_collector: Vec<&str> = Vec::new(); for i in 0..dyn_route_segments.len() { if dyn_route_segments[i].starts_with("[...") { diff --git a/crates/tuono_lib_macros/src/lib.rs b/crates/tuono_lib_macros/src/lib.rs index c40e5f84..356dbe8f 100644 --- a/crates/tuono_lib_macros/src/lib.rs +++ b/crates/tuono_lib_macros/src/lib.rs @@ -19,3 +19,13 @@ pub fn handler(args: TokenStream, item: TokenStream) -> TokenStream { pub fn api(args: TokenStream, item: TokenStream) -> TokenStream { api::api_core(args, item) } + +/// Automatically generate typescript's types +/// from Rust's structs, types and enums. +/// +/// The types will be exported on the client side +/// and it will be available from the `"tuono/types"` module. +#[proc_macro_derive(Type)] +pub fn derive_typescript_type(_: TokenStream) -> TokenStream { + TokenStream::new() +} diff --git a/e2e/fixtures/base/src/routes/index.rs b/e2e/fixtures/base/src/routes/index.rs index 72ddb9e7..8aef764d 100644 --- a/e2e/fixtures/base/src/routes/index.rs +++ b/e2e/fixtures/base/src/routes/index.rs @@ -1,7 +1,7 @@ use serde::Serialize; -use tuono_lib::{Props, Request, Response}; +use tuono_lib::{Props, Request, Response, Type}; -#[derive(Serialize)] +#[derive(Serialize, Type)] struct MyResponse<'a> { subtitle: &'a str, } diff --git a/e2e/fixtures/base/src/routes/index.tsx b/e2e/fixtures/base/src/routes/index.tsx index 8f809d57..fea56138 100644 --- a/e2e/fixtures/base/src/routes/index.tsx +++ b/e2e/fixtures/base/src/routes/index.tsx @@ -1,15 +1,12 @@ import type { JSX } from 'react' import type { TuonoRouteProps } from 'tuono' import { Link } from 'tuono' - -interface IndexProps { - subtitle: string -} +import type { MyResponse } from 'tuono/types' export default function IndexPage({ data, isLoading, -}: TuonoRouteProps): JSX.Element { +}: TuonoRouteProps): JSX.Element { if (isLoading) { return

Loading...

} diff --git a/e2e/fixtures/base/tsconfig.json b/e2e/fixtures/base/tsconfig.json index 39d76807..370fc649 100644 --- a/e2e/fixtures/base/tsconfig.json +++ b/e2e/fixtures/base/tsconfig.json @@ -20,5 +20,5 @@ "noUnusedParameters": true, "noFallthroughCasesInSwitch": true }, - "include": ["src", "tuono.config.ts"] + "include": ["src", "tuono.config.ts", "./.tuono/types.ts"] } diff --git a/examples/tuono-app/tsconfig.json b/examples/tuono-app/tsconfig.json index aeced3d3..0476f956 100644 --- a/examples/tuono-app/tsconfig.json +++ b/examples/tuono-app/tsconfig.json @@ -28,5 +28,5 @@ // Completeness "skipLibCheck": true }, - "include": ["src", "tuono.config.ts"] + "include": ["src", "tuono.config.ts", "./.tuono/types.ts"] } diff --git a/examples/tuono-tutorial/tsconfig.json b/examples/tuono-tutorial/tsconfig.json index aeced3d3..0476f956 100644 --- a/examples/tuono-tutorial/tsconfig.json +++ b/examples/tuono-tutorial/tsconfig.json @@ -28,5 +28,5 @@ // Completeness "skipLibCheck": true }, - "include": ["src", "tuono.config.ts"] + "include": ["src", "tuono.config.ts", "./.tuono/types.ts"] } diff --git a/examples/with-mdx/tsconfig.json b/examples/with-mdx/tsconfig.json index aeced3d3..0476f956 100644 --- a/examples/with-mdx/tsconfig.json +++ b/examples/with-mdx/tsconfig.json @@ -28,5 +28,5 @@ // Completeness "skipLibCheck": true }, - "include": ["src", "tuono.config.ts"] + "include": ["src", "tuono.config.ts", "./.tuono/types.ts"] } diff --git a/examples/with-tailwind/tsconfig.json b/examples/with-tailwind/tsconfig.json index aeced3d3..0476f956 100644 --- a/examples/with-tailwind/tsconfig.json +++ b/examples/with-tailwind/tsconfig.json @@ -28,5 +28,5 @@ // Completeness "skipLibCheck": true }, - "include": ["src", "tuono.config.ts"] + "include": ["src", "tuono.config.ts", "./.tuono/types.ts"] }