Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion packages/eclipsa/core/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,17 @@ export * from './snapshot.ts'
export * from './ssr.ts'
export * from './suspense.ts'
export { RESUME_FINAL_STATE_ELEMENT_ID } from './runtime.ts'
export type { LinkPrefetchMode, Navigate, NavigateOptions } from './router-shared.ts'
export {
buildRoutePath,
createRouteHref,
} from './router-shared.ts'
export type {
LinkPrefetchMode,
Navigate,
NavigateOptions,
RoutePathParams,
RouteSearchParamsInput,
RouteTarget,
} from './router-shared.ts'
export * from './types.ts'
export * from './flow/mod.ts'
192 changes: 192 additions & 0 deletions packages/eclipsa/core/router-shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,76 @@ export interface NavigateOptions {
replace?: boolean
}

export type RoutePathParamValue = string | number | boolean
export type RoutePathParamInput = RoutePathParamValue | null | undefined
export type RoutePathRestParamInput = RoutePathParamValue | readonly RoutePathParamValue[]
export type RouteSearchParamValue =
| RoutePathParamValue
| readonly RoutePathParamValue[]
| null
| undefined
export type RouteSearchParamsInput = URLSearchParams | Record<string, RouteSearchParamValue>

type TrimLeadingSlash<Path extends string> = Path extends `/${infer Rest}`
? TrimLeadingSlash<Rest>
: Path

type TrimTrailingSlash<Path extends string> = Path extends `${infer Rest}/`
? TrimTrailingSlash<Rest>
: Path

type NormalizePath<Path extends string> = TrimTrailingSlash<TrimLeadingSlash<Path>>

type SplitPath<Path extends string> = Path extends `${infer Head}/${infer Tail}`
? [Head, ...SplitPath<Tail>]
: [Path]

type PathSegments<Path extends string> = NormalizePath<Path> extends ''
? []
: SplitPath<NormalizePath<Path>>

type RequiredParamName<Segment extends string> = Segment extends `[${infer Name}]`
? Segment extends `[[${string}]]`
? never
: Segment extends `[...${string}]`
? never
: Name
: never

type OptionalParamName<Segment extends string> = Segment extends `[[${infer Name}]]` ? Name : never

type RestParamName<Segment extends string> = Segment extends `[...${infer Name}]` ? Name : never

type RouteRequiredParamNames<Path extends string> = RequiredParamName<PathSegments<Path>[number]>
type RouteOptionalParamNames<Path extends string> = OptionalParamName<PathSegments<Path>[number]>
type RouteRestParamNames<Path extends string> = RestParamName<PathSegments<Path>[number]>

export type RoutePathParams<Path extends string> = string extends Path
? Record<string, RoutePathParamInput | RoutePathRestParamInput>
: {
[Name in RouteRequiredParamNames<Path>]: RoutePathParamValue
} & {
[Name in RouteOptionalParamNames<Path>]?: RoutePathParamInput
} & {
[Name in RouteRestParamNames<Path>]: RoutePathRestParamInput
}

type RoutePathParamsArg<Path extends string> = string extends Path
? { params?: RoutePathParams<Path> }
: keyof RoutePathParams<Path> extends never
? { params?: RoutePathParams<Path> }
: { params: RoutePathParams<Path> }

export type RouteTarget<Path extends string = string> = {
hash?: string
replace?: boolean
search?: RouteSearchParamsInput
to: Path
} & RoutePathParamsArg<Path>

export interface Navigate {
(href: string, options?: NavigateOptions): Promise<void>
<Path extends string>(target: RouteTarget<Path>): Promise<void>
readonly isNavigating: boolean
}

Expand All @@ -36,3 +104,127 @@ export interface RouteModuleManifest {
}

export type RouteManifest = RouteModuleManifest[]

const normalizeRoutePathTemplate = (path: string) => {
const trimmed = path.trim()
if (trimmed === '' || trimmed === '/') {
return '/'
}
return `/${trimmed.replace(/^\/+|\/+$/g, '')}`
}

const toTemplateSegments = (path: string) =>
normalizeRoutePathTemplate(path)
.split('/')
.filter(Boolean)

const encodeRouteSegmentValue = (value: RoutePathParamValue) => encodeURIComponent(String(value))

const appendSearchParams = (search: URLSearchParams, value: Record<string, RouteSearchParamValue>) => {
for (const [key, rawValue] of Object.entries(value)) {
if (rawValue === null || rawValue === undefined) {
continue
}
if (Array.isArray(rawValue)) {
for (const entry of rawValue) {
search.append(key, String(entry))
}
continue
}
search.append(key, String(rawValue))
}
}

export const buildRoutePath = <Path extends string>(
path: Path,
params?: RoutePathParams<Path>,
): string => {
const segments = toTemplateSegments(path)
if (segments.length === 0) {
return '/'
}

const resolved: string[] = []
for (const segment of segments) {
const optionalMatch = /^\[\[([^\]]+)\]\]$/.exec(segment)
if (optionalMatch) {
const value = params?.[optionalMatch[1]! as keyof RoutePathParams<Path>]
if (value === null || value === undefined) {
continue
}
if (Array.isArray(value)) {
throw new Error(`Optional route parameter "${optionalMatch[1]}" does not accept array values.`)
}
resolved.push(encodeRouteSegmentValue(value as RoutePathParamValue))
continue
}

const restMatch = /^\[\.\.\.([^\]]+)\]$/.exec(segment)
if (restMatch) {
const value = params?.[restMatch[1]! as keyof RoutePathParams<Path>]
if (value === null || value === undefined) {
throw new Error(`Missing route parameter "${restMatch[1]}" for path "${path}".`)
}
if (Array.isArray(value)) {
if (value.length === 0) {
throw new Error(`Route parameter "${restMatch[1]}" requires at least one segment.`)
}
for (const entry of value) {
resolved.push(encodeRouteSegmentValue(entry))
}
} else {
resolved.push(encodeRouteSegmentValue(value as RoutePathParamValue))
}
continue
}

const requiredMatch = /^\[([^\]]+)\]$/.exec(segment)
if (requiredMatch) {
const value = params?.[requiredMatch[1]! as keyof RoutePathParams<Path>]
if (value === null || value === undefined || Array.isArray(value)) {
throw new Error(`Missing route parameter "${requiredMatch[1]}" for path "${path}".`)
}
resolved.push(encodeRouteSegmentValue(value as RoutePathParamValue))
continue
}

resolved.push(segment)
}

return `/${resolved.join('/')}`
}

export const createRouteHref = <Path extends string>(target: RouteTarget<Path>): string => {
const pathname = buildRoutePath(target.to, target.params)
const searchParams = new URLSearchParams()

if (target.search) {
if (target.search instanceof URLSearchParams) {
for (const [key, value] of target.search.entries()) {
searchParams.append(key, value)
}
} else {
appendSearchParams(searchParams, target.search)
}
}

const query = searchParams.toString()
const hash = target.hash ? (target.hash.startsWith('#') ? target.hash : `#${target.hash}`) : ''
return `${pathname}${query ? `?${query}` : ''}${hash}`
}

export const normalizeNavigateInput = <Path extends string>(
input: string | RouteTarget<Path>,
options?: NavigateOptions,
): { href: string; replace: boolean } => {
if (typeof input === 'string') {
return {
href: input,
replace: options?.replace ?? false,
}
}
return {
href: createRouteHref(input),
replace: options?.replace ?? input.replace ?? false,
}
}
34 changes: 34 additions & 0 deletions packages/eclipsa/core/router-types.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest'

import { buildRoutePath, createRouteHref, type RoutePathParams } from './router-shared.ts'

describe('router typed APIs', () => {
it('keeps type-safe path parameter requirements', () => {
const validParams: RoutePathParams<'/posts/[id]/[[tab]]/[...rest]'> = {
id: '42',
rest: ['comments', 'latest'],
}

const href = createRouteHref({
to: '/posts/[id]/[[tab]]/[...rest]',
params: validParams,
hash: 'tail',
})

expect(href).toBe('/posts/42/comments/latest#tail')
expect(buildRoutePath('/posts/[id]', { id: '99' })).toBe('/posts/99')

const ensureTypeErrors = () => {
// @ts-expect-error missing required path params
createRouteHref({ to: '/posts/[id]' })

// @ts-expect-error rest params are required on [...rest]
buildRoutePath('/posts/[id]/[...rest]', { id: '10' })

// @ts-expect-error unknown params are rejected
buildRoutePath('/posts/[id]', { id: '1', extra: 'x' })
}

void ensureTypeErrors
})
})
Comment on lines +1 to +34
55 changes: 55 additions & 0 deletions packages/eclipsa/core/router.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'
import { component$ } from './component.ts'
import { __eclipsaComponent } from './internal.ts'
import { Link, useNavigate } from './router.tsx'
import { buildRoutePath, createRouteHref } from './router-shared.ts'
import { renderSSR } from './ssr.ts'

describe('useNavigate', () => {
Expand Down Expand Up @@ -44,4 +45,58 @@ describe('Link', () => {
expect(enabled.html).toContain('data-e-link-prefetch="hover"')
expect(enabled.html).not.toContain(' prefetch=')
})

it('accepts route targets and resolves them into href', () => {
const rendered = renderSSR(() => (
<Link
to="/posts/[id]/[[tab]]"
params={{
id: 12,
tab: 'comments',
}}
>
Post
</Link>
))

expect(rendered.html).toContain('href="/posts/12/comments"')
})
})

describe('typed route helpers', () => {
it('builds route paths with required optional and rest params', () => {
expect(
buildRoutePath('/blog/[slug]/[[tab]]/[...rest]', {
slug: 'hello world',
tab: 'meta',
rest: ['a', 'b'],
}),
).toBe('/blog/hello%20world/meta/a/b')

expect(
buildRoutePath('/blog/[slug]/[[tab]]', {
slug: 'hello',
}),
).toBe('/blog/hello')
})

it('builds href values with query and hash', () => {
expect(
createRouteHref({
to: '/blog/[slug]',
params: { slug: 'typed-routing' },
search: {
draft: true,
tag: ['framework', 'router'],
},
hash: 'intro',
}),
).toBe('/blog/typed-routing?draft=true&tag=framework&tag=router#intro')
})

it('throws for missing required params', () => {
expect(() => buildRoutePath('/blog/[slug]', {} as never)).toThrow(
'Missing route parameter "slug"',
)
})
})
Loading
Loading