"""Route metadata extraction and Ziggy-compatible generation."""
import contextlib
import re
from collections.abc import Generator
from dataclasses import dataclass, field
from typing import Any, cast
from litestar import Litestar
from litestar._openapi.datastructures import OpenAPIContext # pyright: ignore[reportPrivateUsage]
from litestar._openapi.parameters import ( # pyright: ignore[reportPrivateUsage,reportPrivateImportUsage]
create_parameters_for_handler,
)
from litestar.handlers import HTTPRouteHandler
from litestar.routes import HTTPRoute
from litestar_vite._codegen.ts import normalize_path, ts_type_from_openapi
_PATH_PARAM_EXTRACT_PATTERN = re.compile(r"\{([^:}]+)(?::([^}]+))?\}")
_TS_SEMANTIC_ALIASES: dict[str, tuple[str, str]] = {
"UUID": ("UUID v4 string", "string"),
"DateTime": ("RFC 3339 date-time string", "string"),
"DateOnly": ("ISO 8601 date string (YYYY-MM-DD)", "string"),
"TimeOnly": ("ISO 8601 time string", "string"),
"Duration": ("ISO 8601 duration string", "string"),
"Email": ("Email address string", "string"),
"URI": ("URI/URL string", "string"),
"IPv4": ("IPv4 address string", "string"),
"IPv6": ("IPv6 address string", "string"),
}
def _str_dict_factory() -> dict[str, str]:
"""Return an empty ``dict[str, str]`` (typed for pyright).
Returns:
An empty dictionary.
"""
return {}
def _extract_path_params(path: str) -> dict[str, str]:
"""Extract path parameters and their types from a route.
Args:
path: The route path template.
Returns:
Mapping of parameter name to TypeScript type.
"""
return {match.group(1): "string" for match in _PATH_PARAM_EXTRACT_PATTERN.finditer(path)}
def _iter_route_handlers(app: Litestar) -> Generator[tuple["HTTPRoute", HTTPRouteHandler], None, None]:
"""Iterate over HTTP route handlers in an app.
Args:
app: The Litestar application.
Yields:
Tuples of (HTTPRoute, HTTPRouteHandler).
"""
for route in app.routes:
if isinstance(route, HTTPRoute):
for route_handler in route.route_handlers:
yield route, route_handler
def _extract_params_from_litestar(
handler: HTTPRouteHandler, http_route: "HTTPRoute", openapi_context: OpenAPIContext | None
) -> tuple[dict[str, str], dict[str, str]]:
"""Extract path and query parameters using Litestar's native OpenAPI generation.
Args:
handler: The route handler.
http_route: The HTTP route.
openapi_context: The OpenAPI context, if available.
Returns:
A tuple of (path_params, query_params) maps.
"""
path_params: dict[str, str] = {}
query_params: dict[str, str] = {}
if openapi_context is None:
return path_params, query_params
try:
route_path_params = http_route.path_parameters
params = create_parameters_for_handler(openapi_context, handler, route_path_params)
for param in params:
schema_dict = param.schema.to_schema() if param.schema else None
ts_type = ts_type_from_openapi(schema_dict or {}) if schema_dict else "any"
# For URL generation, `null` is not a meaningful value (it would stringify to "null").
# Treat `null` as "missing" rather than emitting `| null` into route parameter types.
ts_type = ts_type.replace(" | null", "").replace("null | ", "")
if not param.required and ts_type != "any" and "undefined" not in ts_type:
ts_type = f"{ts_type} | undefined"
match param.param_in:
case "path":
path_params[param.name] = ts_type.replace(" | undefined", "")
case "query":
query_params[param.name] = ts_type
case _:
pass
except (AttributeError, TypeError, ValueError, KeyError):
pass
return path_params, query_params
def _make_unique_name(base_name: str, used_names: set[str], path: str, methods: list[str]) -> str:
"""Generate a unique route name, avoiding collisions.
Returns:
A unique route name.
"""
if base_name not in used_names:
return base_name
path_suffix = path.strip("/").replace("/", "_").replace("{", "").replace("}", "").replace("-", "_")
method_suffix = methods[0].lower() if methods else ""
candidate = f"{base_name}_{path_suffix}" if path_suffix else base_name
if candidate not in used_names:
return candidate
candidate = f"{base_name}_{path_suffix}_{method_suffix}" if path_suffix else f"{base_name}_{method_suffix}"
if candidate not in used_names:
return candidate
counter = 2
while f"{candidate}_{counter}" in used_names:
counter += 1
return f"{candidate}_{counter}"
[docs]
def generate_routes_json(
app: Litestar,
*,
only: "list[str] | None" = None,
exclude: "list[str] | None" = None,
include_components: bool = False,
openapi_schema: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Generate Ziggy-compatible routes JSON.
The output is deterministic: routes are sorted by name to produce
byte-identical output for the same input data.
Returns:
A Ziggy-compatible routes payload as a dictionary with sorted keys.
"""
routes_metadata = extract_route_metadata(app, only=only, exclude=exclude, openapi_schema=openapi_schema)
# Sort routes by name for deterministic output
sorted_routes = sorted(routes_metadata, key=lambda r: r.name)
routes_dict: dict[str, Any] = {}
for route in sorted_routes:
route_data: dict[str, Any] = {"uri": route.path, "methods": route.methods}
if route.params:
# Sort params dict for deterministic output
sorted_params = dict(sorted(route.params.items()))
route_data["parameters"] = list(sorted_params.keys())
route_data["parameterTypes"] = sorted_params
if route.query_params:
# Sort query params for deterministic output
route_data["queryParameters"] = dict(sorted(route.query_params.items()))
if include_components and route.component:
route_data["component"] = route.component
routes_dict[route.name] = route_data
return {"routes": routes_dict}
_TS_TYPE_MAP: dict[str, str] = {
"string": "string",
"integer": "number",
"number": "number",
"boolean": "boolean",
"array": "unknown[]",
"object": "Record<string, unknown>",
"uuid": "string",
"date": "string",
"date-time": "string",
"email": "string",
"uri": "string",
"url": "string",
"int": "number",
"float": "number",
"str": "string",
"bool": "boolean",
"path": "string",
"unknown": "unknown",
}
def _ts_type_for_param(param_type: str) -> str:
"""Map a parameter type string to TypeScript type.
Returns:
The TypeScript type for the parameter.
"""
is_optional = "undefined" in param_type or param_type.endswith("?")
clean_type = param_type.replace(" | undefined", "").replace("?", "").strip()
ts_type = _TS_TYPE_MAP.get(clean_type) or clean_type or "string"
if is_optional and "undefined" not in ts_type:
return f"{ts_type} | undefined"
return ts_type
def _is_type_required(param_type: str) -> bool:
"""Check if a parameter type indicates a required field.
Returns:
True if the parameter is required, otherwise False.
"""
return "undefined" not in param_type and not param_type.endswith("?")
def _escape_ts_string(s: str) -> str:
"""Escape a string for use in TypeScript string literals.
Returns:
The escaped string.
"""
return s.replace("\\", "\\\\").replace("'", "\\'").replace('"', '\\"')
[docs]
def generate_routes_ts(
app: Litestar,
*,
only: "list[str] | None" = None,
exclude: "list[str] | None" = None,
openapi_schema: dict[str, Any] | None = None,
global_route: bool = False,
) -> str:
"""Generate typed routes TypeScript file (Ziggy-style).
The output is deterministic: routes are sorted by name to produce
byte-identical output for the same input data.
Returns:
The generated TypeScript source.
"""
routes_metadata = extract_route_metadata(app, only=only, exclude=exclude, openapi_schema=openapi_schema)
# Sort routes by name for deterministic output
sorted_routes = sorted(routes_metadata, key=lambda r: r.name)
route_names: list[str] = []
path_params_entries: list[str] = []
query_params_entries: list[str] = []
routes_entries: list[str] = []
used_aliases: set[str] = set()
for route in sorted_routes:
route_name = route.name
route_names.append(route_name)
# Sort params for deterministic output
sorted_params = dict(sorted(route.params.items())) if route.params else {}
sorted_query_params = dict(sorted(route.query_params.items())) if route.query_params else {}
if sorted_params:
param_fields: list[str] = []
for param_name, param_type in sorted_params.items():
ts_type = _ts_type_for_param(param_type)
ts_type_clean = ts_type.replace(" | undefined", "")
used_aliases.update(_collect_semantic_aliases(ts_type_clean))
param_fields.append(f" {param_name}: {ts_type_clean};")
path_params_entries.append(f" '{route_name}': {{\n" + "\n".join(param_fields) + "\n };")
else:
path_params_entries.append(f" '{route_name}': Record<string, never>;")
if sorted_query_params:
query_param_fields: list[str] = []
for param_name, param_type in sorted_query_params.items():
ts_type = _ts_type_for_param(param_type)
is_required = _is_type_required(param_type)
ts_type_clean = ts_type.replace(" | undefined", "")
used_aliases.update(_collect_semantic_aliases(ts_type_clean))
if is_required:
query_param_fields.append(f" {param_name}: {ts_type_clean};")
else:
query_param_fields.append(f" {param_name}?: {ts_type_clean};")
query_params_entries.append(f" '{route_name}': {{\n" + "\n".join(query_param_fields) + "\n };")
else:
query_params_entries.append(f" '{route_name}': Record<string, never>;")
methods_str = ", ".join(f"'{m}'" for m in sorted(route.methods))
route_entry_lines = [
f" '{route_name}': {{",
f" path: '{_escape_ts_string(route.path)}',",
f" methods: [{methods_str}] as const,",
]
param_names_str = ", ".join(f"'{p}'" for p in sorted_params) if sorted_params else ""
route_entry_lines.append(f" pathParams: [{param_names_str}] as const,")
query_names_str = ", ".join(f"'{p}'" for p in sorted_query_params) if sorted_query_params else ""
route_entry_lines.append(f" queryParams: [{query_names_str}] as const,")
if route.component:
route_entry_lines.append(f" component: '{_escape_ts_string(route.component)}',")
route_entry_lines.append(" },")
routes_entries.append("\n".join(route_entry_lines))
route_names_union = "\n | ".join(f"'{name}'" for name in route_names) if route_names else "never"
alias_block = _render_semantic_aliases(used_aliases)
alias_preamble = f"{alias_block}\n\n" if alias_block else ""
global_route_snippet = ""
if global_route:
global_route_snippet = (
"\n\n// Optionally register route() on window for global access\n"
"if (typeof window !== 'undefined') {\n"
" (window as any).route = route;\n"
"}\n"
)
return f"""// AUTO-GENERATED by litestar-vite. Do not edit.
/* eslint-disable */
// API base URL - only needed for separate dev servers
// Set VITE_API_URL=http://localhost:8000 when running Vite separately
const API_URL = (typeof import.meta !== 'undefined' && (import.meta as any).env?.VITE_API_URL) ?? '';
{alias_preamble}
/** All available route names */
export type RouteName =
| {route_names_union};
/** Path parameter definitions per route */
export interface RoutePathParams {{
{chr(10).join(path_params_entries)}
}}
/** Query parameter definitions per route */
export interface RouteQueryParams {{
{chr(10).join(query_params_entries)}
}}
type EmptyParams = Record<string, never>
type MergeParams<A, B> =
A extends EmptyParams ? (B extends EmptyParams ? EmptyParams : B) : B extends EmptyParams ? A : A & B
/** Combined parameters (path + query) */
export type RouteParams<T extends RouteName> = MergeParams<RoutePathParams[T], RouteQueryParams[T]>
/** Route metadata */
export const routeDefinitions = {{
{chr(10).join(routes_entries)}
}} as const
/** Check if path params are required for a route */
type HasRequiredPathParams<T extends RouteName> =
RoutePathParams[T] extends Record<string, never> ? false : true;
/** Check if query params have any required fields */
type HasRequiredQueryParams<T extends RouteName> =
RouteQueryParams[T] extends Record<string, never>
? false
: Partial<RouteQueryParams[T]> extends RouteQueryParams[T]
? false
: true;
/** Routes that require parameters (path or query) */
type RoutesWithRequiredParams = {{
[K in RouteName]: HasRequiredPathParams<K> extends true
? K
: HasRequiredQueryParams<K> extends true
? K
: never;
}}[RouteName];
/** Routes without any required parameters */
type RoutesWithoutRequiredParams = Exclude<RouteName, RoutesWithRequiredParams>;
/**
* Generate a URL for a named route.
*
* @example
* route('books') // '/api/books'
* route('book_detail', {{ book_id: 123 }}) // '/api/books/123'
* route('search', {{ q: 'test', limit: 5 }}) // '/api/search?q=test&limit=5'
*/
export function route<T extends RoutesWithoutRequiredParams>(name: T): string;
export function route<T extends RoutesWithoutRequiredParams>(
name: T,
params?: RouteParams<T>,
): string;
export function route<T extends RoutesWithRequiredParams>(
name: T,
params: RouteParams<T>,
): string;
export function route<T extends RouteName>(
name: T,
params?: RouteParams<T>,
): string {{
const def = routeDefinitions[name];
let url: string = def.path;
// Replace path parameters (use replaceAll to handle multiple occurrences)
if (params) {{
for (const param of def.pathParams) {{
const value = (params as Record<string, unknown>)[param];
if (value !== undefined) {{
url = url.replaceAll("{{" + param + "}}", String(value));
}}
}}
}}
// Add query parameters
if (params) {{
const queryParts: string[] = [];
for (const param of def.queryParams) {{
const value = (params as Record<string, unknown>)[param];
if (value !== undefined) {{
queryParts.push(encodeURIComponent(param) + "=" + encodeURIComponent(String(value)));
}}
}}
if (queryParts.length > 0) {{
url += "?" + queryParts.join("&");
}}
}}
// Apply API URL if set (for separate dev servers)
return API_URL ? API_URL.replace(/\\/$/, '') + url : url;
}}
/** Check if a route exists */
export function hasRoute(name: string): name is RouteName {{
return name in routeDefinitions;
}}
/** Get all route names */
export function getRouteNames(): RouteName[] {{
return Object.keys(routeDefinitions) as RouteName[];
}}
/** Get route metadata */
export function getRoute<T extends RouteName>(name: T): (typeof routeDefinitions)[T] {{
return routeDefinitions[name];
}}
// ============================================================================
// Route Matching Helpers
// ============================================================================
/** Cache for compiled route patterns */
const patternCache = new Map<string, RegExp>();
/**
* Compile a route path pattern to a regex for URL matching.
* Results are cached for performance.
*/
function compilePattern(path: string): RegExp {{
const cached = patternCache.get(path);
if (cached) return cached;
// Escape special regex characters except {{ }}
let pattern = path.replace(/[.*+?^$|()\\[\\]]/g, '\\\\$&');
// Replace {{param}} or {{param:type}} with matchers
pattern = pattern.replace(/\\{{([^}}]+)\\}}/g, (_match, paramSpec: string) => {{
const paramType = paramSpec.includes(':') ? paramSpec.split(':')[1] : 'str';
switch (paramType) {{
case 'uuid':
return '[0-9a-f]{{8}}-[0-9a-f]{{4}}-[0-9a-f]{{4}}-[0-9a-f]{{4}}-[0-9a-f]{{12}}';
case 'path':
return '.*';
case 'int':
return '\\\\d+';
default:
return '[^/]+';
}}
}});
const regex = new RegExp(`^${{pattern}}$`, 'i');
patternCache.set(path, regex);
return regex;
}}
/**
* Convert a URL to its corresponding route name.
*
* @param url - URL or path to match (query strings and hashes are stripped)
* @returns The matching route name, or null if no match found
*
* @example
* toRoute('/api/books') // 'books'
* toRoute('/api/books/123') // 'book_detail'
* toRoute('/unknown') // null
*/
export function toRoute(url: string): RouteName | null {{
// Strip query string and hash
const path = url.split('?')[0].split('#')[0];
// Normalize: remove trailing slash except for root
const normalized = path === '/' ? path : path.replace(/\\/$/, '');
for (const [name, def] of Object.entries(routeDefinitions)) {{
if (compilePattern(def.path).test(normalized)) {{
return name as RouteName;
}}
}}
return null;
}}
/**
* Get the current route name based on the browser URL.
* Returns null in SSR/non-browser environments.
*
* @returns Current route name, or null if no match or not in browser
*
* @example
* // On page /api/books/123
* currentRoute() // 'book_detail'
*/
export function currentRoute(): RouteName | null {{
if (typeof window === 'undefined') return null;
return toRoute(window.location.pathname);
}}
/**
* Check if a URL matches a route name or pattern.
* Supports wildcard patterns with `*` to match multiple routes.
*
* @param url - URL or path to check
* @param pattern - Route name or pattern (e.g., 'books', 'book_*', '*_detail')
* @returns True if the URL matches the route pattern
*
* @example
* isRoute('/api/books', 'books') // true
* isRoute('/api/books/123', 'book_*') // true (wildcard)
*/
export function isRoute(url: string, pattern: string): boolean {{
const routeName = toRoute(url);
if (!routeName) return false;
// Escape special regex chars (except *), then convert * to .*
const escaped = pattern.replace(/[.+?^$|()\\[\\]{{}}]/g, '\\\\$&');
const regex = new RegExp(`^${{escaped.replace(/\\*/g, '.*')}}$`);
return regex.test(routeName);
}}
/**
* Check if the current browser URL matches a route name or pattern.
* Supports wildcard patterns with `*` to match multiple routes.
* Returns false in SSR/non-browser environments.
*
* @param pattern - Route name or pattern (e.g., 'books', 'book_*', '*_page')
* @returns True if current URL matches the route pattern
*
* @example
* // On page /books
* isCurrentRoute('books_page') // true
* isCurrentRoute('*_page') // true (wildcard)
*/
export function isCurrentRoute(pattern: string): boolean {{
const current = currentRoute();
if (!current) return false;
// Escape special regex chars (except *), then convert * to .*
const escaped = pattern.replace(/[.+?^$|()\\[\\]{{}}]/g, '\\\\$&');
const regex = new RegExp(`^${{escaped.replace(/\\*/g, '.*')}}$`);
return regex.test(current);
}}
{global_route_snippet}
"""
def _collect_semantic_aliases(type_expr: str) -> set[str]:
return {alias for alias in _TS_SEMANTIC_ALIASES if alias in type_expr}
def _render_semantic_aliases(aliases: set[str]) -> str:
if not aliases:
return ""
lines: list[str] = ["/** Semantic string aliases derived from OpenAPI `format`. */"]
for alias in sorted(aliases):
doc, base = _TS_SEMANTIC_ALIASES[alias]
lines.extend((f"/** {doc} */", f"export type {alias} = {base};", ""))
return "\n".join(lines).rstrip()