Skip to content
Open
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
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions packages/docs/src/registry/items/parse-as-tuple.md
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: usage files should remain next to their source files.

Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
The `parseAsTuple` parser allows you to parse fixed-length tuples with **any type** for each position.

```ts
import { parseAsTuple } from '@/lib/parsers/parse-as-tuple'
import { parseAsInteger } from 'nuqs'

// Coordinates tuple (x, y)
parseAsTuple([parseAsInteger, parseAsInteger])

// Optionally, customise the separator
parseAsTuple([parseAsInteger, parseAsInteger], ';')
```
8 changes: 7 additions & 1 deletion packages/nuqs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@
"adapters/react-router/v7.d.ts",
"adapters/tanstack-router.d.ts",
"adapters/custom.d.ts",
"adapters/testing.d.ts"
"adapters/testing.d.ts",
"lib/index.d.ts"
],
"type": "module",
"sideEffects": false,
Expand Down Expand Up @@ -125,6 +126,11 @@
"types": "./dist/adapters/testing.d.ts",
"import": "./dist/adapters/testing.js",
"default": "./dist/adapters/testing.js"
},
"./lib": {
"types": "./dist/lib.d.ts",
"import": "./dist/lib.js",
"default": "./dist/lib.js"
}
},
"scripts": {
Expand Down
3 changes: 3 additions & 0 deletions packages/nuqs/src/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@ const exports = `
"NuqsTestingAdapter": "function",
"withNuqsTestingAdapter": "function",
},
"./lib": {
"safeParse": "function",
},
"./server": {
"createLoader": "function",
"createMultiParser": "function",
Expand Down
1 change: 1 addition & 0 deletions packages/nuqs/src/lib/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './safe-parse'
3 changes: 2 additions & 1 deletion packages/nuqs/tsdown.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ const entrypoints = {
'adapters/react-router/v7': 'src/adapters/react-router/v7.ts',
'adapters/tanstack-router': 'src/adapters/tanstack-router.ts',
'adapters/custom': 'src/adapters/custom.ts',
'adapters/testing': 'src/adapters/testing.ts'
'adapters/testing': 'src/adapters/testing.ts',
lib: 'src/lib/index.ts'
},
server: {
server: 'src/index.server.ts',
Expand Down
1 change: 1 addition & 0 deletions packages/registry/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
public/r/
30 changes: 30 additions & 0 deletions packages/registry/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# registry

A shadcn/ui compatible registry for community parsers and utilities for the [nuqs](https://nuqs.dev) library.

## Development

```bash
# Install dependencies
pnpm install

# Build the registry
cd ../docs && pnpm build:registry
# Run the docs server
cd ../docs && pnpm dev

```

Usage example in any npm package:

```bash
pnpm dlx shadcn@latest add http://localhost:3000/r/parse-as-tuple.json
```

## Learn More

To learn more about nuqs and shadcn/ui registries, take a look at the following resources:

- [nuqs Documentation](https://nuqs.dev) - learn about nuqs features and API
- [shadcn/ui Registry Documentation](https://ui.shadcn.com/docs/registry) - learn about shadcn/ui registries
- [nuqs GitHub Repository](https://github.com/47ng/nuqs) - source code and issues
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: This one would be difficult to test without having a proper Next.js setup to typegen route definitions from, which we do have in the docs and could test against there.

File renamed without changes.
17 changes: 17 additions & 0 deletions packages/registry/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"name": "registry",
"description": "Shadcn CLI registry for community parsers & utilities",
"scripts": {
"test": "pnpm run --stream '/^test:/'",
"test:unit": "vitest run --typecheck"
},
"dependencies": {
"next": "15.5.0",
"nuqs": "workspace:*",
"shadcn": "^3.4.2"
},
"devDependencies": {
"typescript": "^5.9.2",
"vitest": "^3.2.4"
}
}
41 changes: 41 additions & 0 deletions packages/registry/parsers/parse-as-tuple.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { parseAsBoolean, parseAsInteger, parseAsString } from 'nuqs'
import {
isParserBijective,
testParseThenSerialize,
testSerializeThenParse
} from 'nuqs/testing'
import { describe, expect, it } from 'vitest'
import { parseAsTuple } from './parse-as-tuple'

describe('parseAsTuple', () => {
it('parses and serializes tuples correctly', () => {
const parser = parseAsTuple([parseAsInteger, parseAsString, parseAsBoolean])
expect(parser.parse('1,a,false,will-ignore')).toStrictEqual([1, 'a', false])
expect(parser.parse('not-a-number,a,true')).toBeNull()
expect(parser.parse('1,a')).toBeNull()
// @ts-expect-error - Tuple length is less than 2
expect(() => parseAsTuple([parseAsInteger])).toThrow()
expect(parser.serialize([1, 'a', true])).toBe('1,a,true')
// @ts-expect-error - Tuple length mismatch
expect(() => parser.serialize([1, 'a'])).toThrow()
expect(testParseThenSerialize(parser, '1,a,true')).toBe(true)
expect(testSerializeThenParse(parser, [1, 'a', true] as const)).toBe(true)
expect(isParserBijective(parser, '1,a,true', [1, 'a', true] as const)).toBe(
true
)
expect(() =>
isParserBijective(parser, 'not-a-tuple', [1, 'a', true] as const)
).toThrow()
})

it('equality comparison works correctly', () => {
const eq = parseAsTuple([parseAsInteger, parseAsBoolean]).eq!
expect(eq([1, true], [1, true])).toBe(true)
expect(eq([1, true], [1, false])).toBe(false)
expect(eq([1, true], [2, true])).toBe(false)
// @ts-expect-error - Tuple length mismatch
expect(eq([1, true], [1])).toBe(false)
// @ts-expect-error - Tuple length mismatch
expect(eq([1], [1])).toBe(false)
})
})
74 changes: 74 additions & 0 deletions packages/registry/parsers/parse-as-tuple.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { createParser, type SingleParserBuilder } from 'nuqs'
import { safeParse } from 'nuqs/lib'

type ParserTuple<T extends readonly unknown[]> = {
[K in keyof T]: SingleParserBuilder<T[K]>
} & { length: 2 | 3 | 4 | 5 | 6 | 7 | 8 }

/**
* Parse a comma-separated tuple with type-safe positions.
* Items are URI-encoded for safety, so they may not look nice in the URL.
* allowed tuple length is 2-8.
*
* @param itemParsers Tuple of parsers for each position in the tuple
* @param separator The character to use to separate items (default ',')
*/
export function parseAsTuple<T extends any[]>(
itemParsers: ParserTuple<T>,
separator = ','
): SingleParserBuilder<T> {
const encodedSeparator = encodeURIComponent(separator)
if (itemParsers.length < 2 || itemParsers.length > 8) {
throw new Error(
`Tuple length must be between 2 and 8, got ${itemParsers.length}`
)
}
return createParser<T>({
parse: query => {
if (query === '') {
return null
}
const parts = query.split(separator)
if (parts.length < itemParsers.length) {
return null
}
// iterating by parsers instead of parts, any additional parts are ignored.
const result = itemParsers.map(
(parser, index) =>
safeParse(
parser.parse,
parts[index]!.replaceAll(encodedSeparator, separator),
`[${index}]`
) as T[number] | null
)
return result.some(x => x === null) ? null : (result as T)
},
serialize: (values: T) => {
if (values.length !== itemParsers.length) {
throw new Error(
`Tuple length mismatch: expected ${itemParsers.length}, got ${values.length}`
)
}
return values
.map((value, index) => {
const parser = itemParsers[index]!
const str = parser.serialize ? parser.serialize(value) : String(value)
return str.replaceAll(separator, encodedSeparator)
})
.join(separator)
},
eq(a: T, b: T) {
if (a === b) {
return true
}
if (a.length !== b.length || a.length !== itemParsers.length) {
return false
}
return a.every((value, index) => {
const parser = itemParsers[index]!
const itemEq = parser.eq ?? ((x, y) => x === y)
return itemEq(value, b[index])
})
}
})
}
36 changes: 36 additions & 0 deletions packages/registry/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"jsx": "react",
// Type checking
"strict": true,
"noUncheckedIndexedAccess": true,
"alwaysStrict": false, // Don't emit "use strict" to avoid conflicts with "use client"
// Modules
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
// Language & Environment
"target": "ESNext",
"lib": ["DOM", "DOM.Iterable", "ESNext"],
// Emit
"noEmit": true,
"declaration": true,
"declarationMap": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",

"downlevelIteration": true,
// Interop
"isolatedModules": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
// Misc
"skipLibCheck": true,
"skipDefaultLibCheck": true,
"incremental": true,
"tsBuildInfoFile": ".tsbuildinfo"
},
"include": ["**/*.ts", "**/*.tsx"],
"exclude": ["node_modules", "dist"]
}
11 changes: 11 additions & 0 deletions packages/registry/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { defineConfig, type ViteUserConfig } from 'vitest/config'

const config: ViteUserConfig = defineConfig({
test: {
typecheck: {
tsconfig: './tsconfig.json'
}
}
})

export default config
Loading
Loading