Skip to content
Open
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
2d96275
feat: parseAsTuple (#1036)
I-3B Jul 4, 2025
1fea0c1
feat: add registry package with parseAsTuple item
I-3B Oct 25, 2025
0743e8c
feat: remove parseAsTuple from parsers.test.ts
I-3B Oct 25, 2025
0818109
doc: add README to registry
I-3B Oct 25, 2025
53c8d99
doc(registry): add usage example for parseAsTuple parser
I-3B Oct 26, 2025
ab20790
test(registry): update shadcn to 3.4.2, add vitest for testing, and i…
I-3B Oct 26, 2025
09a0eee
feat(registry): move safe-parse util to registry package, restructur…
I-3B Oct 26, 2025
0bb04a2
chore(registry): reorder dependencies and devDependencies in package.…
I-3B Oct 26, 2025
f76b99d
feat(registry): use nuqs/lib export
I-3B Oct 27, 2025
93640c8
Merge branch 'next' into feat/registry
I-3B Oct 27, 2025
f35ba1b
feat(registry): update docs to use registry package
I-3B Oct 27, 2025
88dad2b
test: add safeParse function export to API test
I-3B Oct 27, 2025
fc7d16f
doc: update parse-as-tuple example to use correct import path
I-3B Oct 29, 2025
0cd611d
Merge branch 'next' into feat/registry
I-3B Nov 23, 2025
98447c2
Merge branch 'next' into feat/registry
I-3B Dec 6, 2025
98841c4
doc(registry): update assemble script to use items from registry package
I-3B Dec 6, 2025
7a096e4
fix(registry): update readUsage function to also lookup usage file fr…
I-3B Dec 7, 2025
821e583
doc(registry): update parseAsTuple usage to show more useful usecases
I-3B Dec 7, 2025
cf615d0
doc(registry): improve comment syntax in parseAsTuple example
I-3B Dec 7, 2025
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
2 changes: 1 addition & 1 deletion packages/docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"dev": "next dev --turbo",
"build": "pnpm run build:registry && pnpm run build:next",
"build:next": "next build",
"build:registry": "shadcn build",
"build:registry": "shadcn build --cwd ../registry --output ../docs/public/r",
"start": "next start",
"postinstall": "fumadocs-mdx",
"test": "tsc"
Expand Down
12 changes: 12 additions & 0 deletions packages/docs/src/app/registry/_usage/parse-as-tuple.md
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])
})
}
})
}
16 changes: 15 additions & 1 deletion packages/docs/registry.json → packages/registry/registry.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,20 @@
"name": "nuqs",
"homepage": "https://nuqs.dev",
"items": [
{
"name": "parse-as-tuple",
"type": "registry:item",
"title": "Tuples",
"description": "Parse query string as a tuple of values.",
"dependencies": ["nuqs"],
"files": [
{
"path": "parsers/parse-as-tuple.ts",
"type": "registry:file",
"target": "~/src/lib/parsers/parse-as-tuple.ts"
}
]
},
{
"type": "registry:item",
"name": "next-typed-links",
Expand All @@ -12,7 +26,7 @@
"files": [
{
"type": "registry:file",
"path": "src/lib/typed-links.ts",
"path": "lib/typed-links.ts",
"target": "~/src/lib/typed-links.ts"
}
]
Expand Down
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