@@ -31,61 +31,70 @@ function runtimeExpressionToUrlPrefix(expression: string): string {
3131 return '${' + t + '}' ;
3232}
3333
34- const hasParam = ( path : string ) : boolean => / [ ^ { ] * { [ \w * _ - ] * } .* / . test ( path ) ;
34+ // Matches a `{name}` path-parameter template and captures the name
35+ // (`{petId}`, `{user_id}`, `{scope.id}`, `{kebab-case}`, `{path*}`). The
36+ // (?<!\$) guard is shared policy for every consumer (template-literal,
37+ // Hono and MSW routes): a `${...}` block in a spec path is never treated
38+ // as an OpenAPI param — it stays literal text in the emitted route. The name
39+ // must be non-empty so a malformed `{}` also stays literal instead of
40+ // emitting an invalid `${}` interpolation.
41+ const PATH_PARAM_REGEX = / (?< ! \$ ) \{ ( [ \w . * - ] + ) \} / g;
42+
43+ // Spec paths are required to start with `/`, but malformed specs without it
44+ // are tolerated by normalizing here.
45+ const ensureLeadingSlash = ( path : string ) : string =>
46+ path && ! path . startsWith ( '/' ) ? `/${ path } ` : path ;
3547
36- const esc = ( str : string ) => jsesc ( str , { quotes : 'backtick' , wrap : false } ) ;
48+ /**
49+ * Sanitizes an OpenAPI path-parameter name while preserving the spec's
50+ * spelling: keeps word characters, underscores, dashes and dots, strips
51+ * everything else, and prefixes ES5 keywords with an underscore. Use this
52+ * when the emitted name must match the spec (e.g. Hono routes).
53+ */
54+ export const sanitizePathParamName = ( name : string ) : string =>
55+ sanitize ( name , { es5keyword : true , underscore : true , dash : true , dot : true } ) ;
3756
38- const getRoutePath = ( path : string ) : string => {
39- // Don't treat ${...} as an OpenAPI path param — the $ makes it literal text,
40- // not a {param} template. Escape the ${...} block and continue processing
41- // any legitimate {param} segments after it.
42- const braceIdx = path . indexOf ( '{' ) ;
43- if ( braceIdx > 0 && path [ braceIdx - 1 ] === '$' ) {
44- const closeIdx = path . indexOf ( '}' , braceIdx ) ;
45- if ( closeIdx === - 1 ) return esc ( path ) ;
46- const before = esc ( path . slice ( 0 , closeIdx + 1 ) ) ;
47- const rest = path . slice ( closeIdx + 1 ) ;
48- return hasParam ( rest )
49- ? `${ before } ${ getRoutePath ( rest ) } `
50- : `${ before } ${ esc ( rest ) } ` ;
51- }
57+ /**
58+ * Derives the generated JS identifier for an OpenAPI path-parameter name
59+ * (`scope.id` → `scopeId`, `_id` → `id`, `class` → `_class`). This is the
60+ * single source of truth for param variable names: the emitted route
61+ * interpolations, the generated function arguments and the spec-parameter
62+ * matching must all agree on it.
63+ */
64+ export const camelPathParamName = ( name : string ) : string =>
65+ sanitize ( camel ( name ) , { es5keyword : true } ) ;
5266
53- const matches = / ( [ ^ { ] * ) { ? ( [ \w * _ - ] * ) } ? ( .* ) / . exec ( path ) ;
54- if ( ! matches ?. length ) return esc ( path ) ;
55-
56- const prev = matches [ 1 ] ;
57- const rawParam = matches [ 2 ] ;
58- const rest = matches [ 3 ] ;
59- const param = sanitize ( camel ( rawParam ) , {
60- es5keyword : true ,
61- underscore : true ,
62- dash : true ,
63- dot : true ,
64- } ) ;
65- const next = hasParam ( rest ) ? getRoutePath ( rest ) : esc ( rest ) ;
66-
67- return hasParam ( path )
68- ? `${ esc ( prev ) } \${${ param } }${ next } `
69- : `${ esc ( prev ) } ${ param } ${ next } ` ;
70- } ;
67+ /**
68+ * Converts every `{param}` in an OpenAPI path to `:param` (Hono/MSW style
69+ * routes). `formatParamName` maps the raw OpenAPI parameter name to the
70+ * emitted one (`sanitizePathParamName` or `camelPathParamName`).
71+ */
72+ export const toColonRoutePath = (
73+ path : string ,
74+ formatParamName : ( rawName : string ) => string ,
75+ ) : string =>
76+ ensureLeadingSlash ( path ) . replaceAll (
77+ PATH_PARAM_REGEX ,
78+ ( _ , name : string ) => `:${ formatParamName ( name ) } ` ,
79+ ) ;
80+
81+ const esc = ( str : string ) => jsesc ( str , { quotes : 'backtick' , wrap : false } ) ;
7182
7283/**
7384 * Converts an OpenAPI path (`{param}`) to a template-literal route (`${param}`),
74- * escaping static segments with jsesc for safe embedding in backtick strings.
75- * The `route` arg must be a raw OpenAPI path.
85+ * escaping static text with jsesc for safe embedding in backtick strings.
86+ * The `route` arg must be a raw OpenAPI path; a non-empty route always emits
87+ * with a leading `/`.
7688 */
7789export function getRoute ( route : string ) {
78- const splittedRoute = route . split ( '/' ) ;
79-
80- let result = '' ;
81- for ( const [ i , path ] of splittedRoute . entries ( ) ) {
82- if ( ! path && ! i ) {
83- continue ;
84- }
85-
86- result += path . includes ( '{' ) ? `/${ getRoutePath ( path ) } ` : `/${ esc ( path ) } ` ;
87- }
88- return result ;
90+ // Splitting on the capture group leaves param names at odd indices and
91+ // literal text at even indices. `${...}` blocks in the spec path fall into
92+ // the literal parts (via the lookbehind) so they are escaped, not
93+ // interpolated.
94+ return ensureLeadingSlash ( route )
95+ . split ( PATH_PARAM_REGEX )
96+ . map ( ( part , i ) => ( i % 2 ? `\${${ camelPathParamName ( part ) } }` : esc ( part ) ) )
97+ . join ( '' ) ;
8998}
9099
91100/**
0 commit comments