Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- Generate TypeScript source, runtime templates, Markdown examples, and JSON
configuration with formatting compatible with Vite+ checks. Lay out long
signatures, imports, unions, and converter expressions directly in Rust.
Enable full `vp check` and validate freshly generated clients before and after
packing, without a formatter preparation step. Exclude `dist/` from formatting
and provide a `fmt` script for manual edits.
- Upgrade generated TypeScript clients to Vite+ `^1.5.0`, including Vite `8.3.5`,
Vitest `5.1.0`, Rolldown `1.2.16`, Oxfmt `0.76.0`, Oxlint `1.91.0`,
oxlint-tsgolint `7.0.2005`, and tsdown `0.23.4`. Pin CI validation and
Expand Down
16 changes: 9 additions & 7 deletions docs/src/golden_testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ CI materializes each golden directory into a temp folder (stripping the `.golden

| Language | Command | Marker file |
|----------|---------|-------------|
| TypeScript | `vp check --no-fmt && vp pack` or `tsc --noEmit` (see below) | `tsconfig.json.golden` |
| TypeScript | `vp check && vp pack && vp check` or `tsc --noEmit` (see below) | `tsconfig.json.golden` |
| Go | `go build ./...` | `go.mod.golden` |
| Rust | `cargo check` | `Cargo.toml.golden` |
| Python | `pyright` | `pyproject.toml.golden` |
Expand All @@ -61,7 +61,7 @@ This catches type errors that snapshot comparison alone cannot.
The TypeScript golden build script selects the toolchain per-test based on whether
`vite.config.ts.golden` exists in the test directory:

- **With `vite.config.ts`** — runs `vp install && vp check --no-fmt && vp pack`. This is the `toolchain = "vp"` path: installs deps, runs type-aware linting, then builds the library bundle.
- **With `vite.config.ts`** — runs `vp install && vp check && vp pack && vp check`. This is the `toolchain = "vp"` path: installs deps, checks the original generated files for formatting and type-aware linting/types, then builds the library bundle. The second check verifies that formatting still passes after packing; generated `dist/` output is excluded from formatting.
- **Without `vite.config.ts`** — runs `tsc --noEmit`. This is the `toolchain = "tsc"` path: type-checks only.

Both paths are always exercised in CI. Both `vp` and `tsc` must be available.
Expand Down Expand Up @@ -121,18 +121,20 @@ just golden-build-all # all languages
just golden-typescript-clients::test
```

This generates fresh preserve/camelCase clients, validates and packs them with
Vite+, checks their packed declarations with strict TypeScript, and runs ordinary
Node tests against the real bundle. Full additional-property payload equality,
This generates fresh preserve/camelCase clients, validates, and packs
them with Vite+, checks their packed declarations with strict TypeScript, and
runs ordinary Node tests against the real bundle. Full additional-property payload equality,
typed dictionary conversion, allOf and search-event composition, renamed-key
collisions, optional-field absence, and prototype-sensitive keys are covered.
The preserve client supplies type-contract coverage, not a second runtime
conversion path (it does not emit converters).

Generation, compilation, and execution are separate just recipes. Builds run in
temporary directories so Vite+ does not skip gitignored generated sources; no
generated manifests or source files are patched. The consumer type check uses
the generated package's installed compiler rather than the global `tsc` selection.
generated manifests or source files receive compatibility patches or formatter
rewrites. Full `vp check` runs on the original generated output, then runs again
after packing. The consumer type check uses the generated package's installed
compiler rather than the global `tsc` selection.
The current global Vite+ CLI also needs TypeScript installed alongside the CLI
for declaration packing (`npm install -g typescript@7.0.2 vite-plus@1.5.0`). CI
reuses that bootstrap and its generator cache in the existing TypeScript/Vite+
Expand Down
17 changes: 16 additions & 1 deletion docs/src/ts_config.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ Variants with "UNSPECIFIED" in the discriminator value, discriminator values equ
Selects the type checking and build toolchain. One of `"tsc"` (default) or `"vp"`.

- `"tsc"` — bare TypeScript compiler. Generates `tsconfig.json` only. Build script: `tsc`.
- `"vp"` — vite-plus. Generates `vite.config.ts` with `pack` config for library output (ESM + .d.ts). Build script: `vp pack`. Check script: `vp check --no-fmt`.
- `"vp"` — vite-plus. Generates `vite.config.ts` with `pack` config for library output (ESM + .d.ts). Build script: `vp pack`. Format script: `vp fmt`. Check script: `vp check`.

When `toolchain = "vp"`, the generated `package.json` includes `vite-plus` (`^1.5.0`) and `typescript` (`^7.0.2`) as dev dependencies, and scripts use `vp pack` (library mode) rather than `vp build` (app mode).

Expand All @@ -124,6 +124,21 @@ component versions.

Vite+ 1.5.0 requires Node.js `^22.18.0 || ^24.11.0 || >=26.0.0`.

After generating or regenerating a Vite+ client, run:

```sh
vp install
vp check
vp pack
```

The generator emits sources, JSON, and Markdown using the configured single-quote
style and standard Oxfmt layout. `vp check` validates formatting, lint rules, and
types directly on these files without modifying them. No formatter is required
between generation and validation. Use `vp fmt` (or `npm run fmt`) for subsequent
manual edits. The generated configuration excludes `dist/`, so checks also pass
after packing. Generation itself requires only Rust, without Node.js or Vite+.

```toml
[generators.typescript-fetch]
toolchain = "vp"
Expand Down
4 changes: 2 additions & 2 deletions just/golden-typescript-clients.just
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Native TypeScript consumer tests against fresh, unmodified generated clients.
# Native TypeScript consumer tests against freshly generated clients checked directly by Vite+.

set working-directory := '..'

Expand All @@ -20,7 +20,7 @@ set working-directory := '..'
# Like golden builds, validate outside the ignored generated directory:
# Vite+ otherwise skips every source file because of the repository ignore.
tar --exclude=node_modules --exclude=dist -C "$output" -cf - . | tar -C "$build" -xf -
(cd "$build" && vp install && vp --version && vp check --no-fmt && vp pack)
(cd "$build" && vp install && vp --version && vp check && vp pack && vp check)
mkdir -p "$output/dist"
cp "$build"/dist/* "$output/dist/"
done
Expand Down
2 changes: 1 addition & 1 deletion scripts/golden-build-typescript.sh
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ for test_dir in "$golden"/*/; do

log=$(mktemp)
if [ "$toolchain" = "vp" ]; then
if (cd "$tmp" && vp install && vp check --no-fmt && vp pack) >>"$log" 2>&1; then
if (cd "$tmp" && vp install && vp check && vp pack && vp check) >>"$log" 2>&1; then
echo "ok"
passed=$((passed + 1))
else
Expand Down
143 changes: 108 additions & 35 deletions src/generators/typescript/fetch/codegen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

use heck::{ToKebabCase as _, ToLowerCamelCase as _, ToPascalCase as _, ToSnakeCase as _};

use super::layout::named_list;
use sigil_stitch::lang::typescript::TypeScript;

use super::config::TypeScriptFetchConfig;
Expand Down Expand Up @@ -98,12 +99,15 @@ impl TypeScriptFetchCodeGenerator {

if value_names.is_empty() {
if extra_types.is_empty() {
exports.push(format!("export type {{ {type_name} }} from './{stem}';"));
exports.push(named_list(
"export type",
std::slice::from_ref(&type_name),
&format!("./{stem}"),
));
} else {
let mut all_types = vec![type_name.clone()];
all_types.extend(extra_types);
let joined = all_types.join(", ");
exports.push(format!("export type {{ {joined} }} from './{stem}';"));
exports.push(named_list("export type", &all_types, &format!("./{stem}")));
}
} else {
let type_name_is_value = value_names.contains(&type_name);
Expand All @@ -121,19 +125,20 @@ impl TypeScriptFetchCodeGenerator {
// `export { X }` covers both the type and value sides.
let mut all_names = vec![type_name.clone()];
all_names.extend(unique_values);
let joined = all_names.join(", ");
exports.push(format!("export {{ {joined} }} from './{stem}';"));
exports.push(named_list("export", &all_names, &format!("./{stem}")));
} else {
// Type is separate from value exports.
// Emit `export type { X, X$Wire }` for types and a separate
// `export { xFromJSON, xToJSON }` for value exports.
let mut all_types = vec![type_name.clone()];
all_types.extend(extra_types);
let type_joined = all_types.join(", ");
exports.push(format!("export type {{ {type_joined} }} from './{stem}';"));
exports.push(named_list("export type", &all_types, &format!("./{stem}")));
if !unique_values.is_empty() {
let joined = unique_values.join(", ");
exports.push(format!("export {{ {joined} }} from './{stem}';"));
exports.push(named_list(
"export",
&unique_values,
&format!("./{stem}"),
));
}
}
}
Expand All @@ -142,7 +147,11 @@ impl TypeScriptFetchCodeGenerator {
let type_name = model.name.to_pascal_case();
let filename = self.generate_filename(&model.name);
let stem = filename.trim_end_matches(".ts");
exports.push(format!("export type {{ {type_name} }} from './{stem}';"));
exports.push(named_list(
"export type",
std::slice::from_ref(&type_name),
&format!("./{stem}"),
));
}
files.push(render_index_file(&ir.info, "models/index.ts", &exports));
}
Expand Down Expand Up @@ -175,8 +184,9 @@ impl TypeScriptFetchCodeGenerator {
for (schema_name, type_names) in imports {
let filename = self.generate_filename(&schema_name);
let stem = filename.trim_end_matches(".ts");
let names = type_names.into_iter().collect::<Vec<_>>().join(", ");
content.push_str(&format!("import type {{ {names} }} from './{stem}';\n"));
let names = type_names.into_iter().collect::<Vec<_>>();
content.push_str(&named_list("import type", &names, &format!("./{stem}")));
content.push('\n');
}
if needs_upload || !model.fields.is_empty() {
content.push('\n');
Expand Down Expand Up @@ -226,7 +236,12 @@ impl TypeScriptFetchCodeGenerator {
request_inputs.has_uploads(),
));

files.push(render_readme_file(ir));
files.push(render_readme_file(
ir,
self.config.toolchain == Toolchain::Vp
&& self.config.generate_package
&& self.config.include_build_scripts,
));

let has_apis = !ir.operations.is_empty();
let has_models =
Expand Down Expand Up @@ -317,10 +332,10 @@ impl TypeScriptFetchCodeGenerator {
let license: Option<String> = ir.info.license.as_ref().map(|l| l.name.clone());

let keywords = vec![
"openapi".to_string(),
"api-client".to_string(),
"typescript".to_string(),
"generated".to_string(),
"openapi".to_string(),
"typescript".to_string(),
];

let package_name = self
Expand Down Expand Up @@ -370,7 +385,8 @@ impl TypeScriptFetchCodeGenerator {
Toolchain::Vp => {
package_json["scripts"] = serde_json::json!({
"build": "vp pack",
"check": "vp check --no-fmt",
"check": "vp check",
"fmt": "vp fmt",
});
package_json["devDependencies"] = serde_json::json!({
"typescript": "^7.0.2",
Expand All @@ -380,8 +396,7 @@ impl TypeScriptFetchCodeGenerator {
}
}

let content =
serde_json::to_string_pretty(&package_json).unwrap_or_else(|_| "{}".to_string());
let content = render_project_json(&package_json, true);

FileInfo::project("package.json".to_string(), content)
}
Expand Down Expand Up @@ -411,7 +426,7 @@ impl TypeScriptFetchCodeGenerator {
});
tsconfig["compilerOptions"]["lib"] = serde_json::json!(self.config.ts_lib);

let content = serde_json::to_string_pretty(&tsconfig).unwrap_or_else(|_| "{}".to_string());
let content = render_project_json(&tsconfig, false);
FileInfo::project("tsconfig.json".to_string(), content)
}

Expand All @@ -426,8 +441,7 @@ impl TypeScriptFetchCodeGenerator {
}
});

let content =
serde_json::to_string_pretty(&tsconfig_esm).unwrap_or_else(|_| "{}".to_string());
let content = render_project_json(&tsconfig_esm, false);
FileInfo::project("tsconfig.esm.json".to_string(), content)
}

Expand All @@ -436,6 +450,10 @@ impl TypeScriptFetchCodeGenerator {
let content = r#"import { defineConfig } from 'vite-plus';

export default defineConfig({
fmt: {
singleQuote: true,
ignorePatterns: ['dist/**'],
},
lint: {
options: {
typeAware: true,
Expand Down Expand Up @@ -592,19 +610,74 @@ impl FileWriter for TypeScriptFetchCodeGenerator {}
/// single-line form fits within ~100 cols, else break each name onto its own
/// line. Keeps short barrels dense and long ones scannable.
fn format_named_reexport(kind: &str, names: &[String], filename_base: &str) -> String {
let one_line = format!(
"{} {{ {} }} from './{}';",
kind,
names.join(", "),
filename_base
);
if names.len() <= 1 || one_line.len() <= 100 {
return one_line;
}
let mut out = format!("{} {{\n", kind);
for name in names {
out.push_str(&format!(" {},\n", name));
named_list(kind, names, &format!("./{filename_base}"))
}

/// Emit JSON with compact configuration arrays and npm's manifest field order.
fn render_project_json(value: &serde_json::Value, package: bool) -> String {
fn render(value: &serde_json::Value, depth: usize, package: bool) -> String {
let pad = " ".repeat(depth);
let inner_pad = " ".repeat(depth + 1);
match value {
serde_json::Value::Object(map) if !map.is_empty() => {
let mut keys = map.keys().collect::<Vec<_>>();
if package && depth == 0 {
let order = [
"name",
"version",
"description",
"keywords",
"license",
"files",
"type",
"main",
"types",
"exports",
"scripts",
"devDependencies",
];
keys.sort_by_key(|key| {
order
.iter()
.position(|known| known == &key.as_str())
.unwrap_or(order.len())
});
}
let entries = keys
.into_iter()
.map(|key| {
format!(
"{inner_pad}{}: {}",
serde_json::to_string(key).expect("JSON key"),
render(&map[key], depth + 1, package)
)
})
.collect::<Vec<_>>();
format!("{{\n{}\n{pad}}}", entries.join(",\n"))
}
serde_json::Value::Array(items) if !items.is_empty() => {
if !package {
format!(
"[{}]",
items
.iter()
.map(|item| serde_json::to_string(item).expect("JSON value"))
.collect::<Vec<_>>()
.join(", ")
)
} else {
format!(
"[\n{}\n{pad}]",
items
.iter()
.map(|item| format!("{inner_pad}{}", render(item, depth + 1, package)))
.collect::<Vec<_>>()
.join(",\n")
)
}
}
_ => serde_json::to_string(value).expect("JSON value"),
}
}
out.push_str(&format!("}} from './{}';", filename_base));
out
format!("{}\n", render(value, 0, package))
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ pub enum PropertyNaming {
/// Toolchain for type checking and building.
///
/// `Tsc` uses bare `tsc` (default).
/// `Vp` uses vite-plus (`vp check --no-fmt`) with type-aware linting.
/// `Vp` uses vite-plus (`vp check`) with formatting and type-aware linting.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Toolchain {
Expand Down Expand Up @@ -92,7 +92,7 @@ pub struct TypeScriptFetchConfig {
/// Toolchain for type checking and building.
///
/// - `tsc` (default): bare TypeScript compiler.
/// - `vp`: vite-plus with type-aware linting and type checking.
/// - `vp`: vite-plus with formatting, type-aware linting, and type checking.
#[serde(default)]
pub toolchain: Toolchain,

Expand Down
Loading
Loading